Compare commits

...

10 Commits

Author SHA1 Message Date
AndyChen1024
84980f0fa9 chore: 添加 gitignored 凭据文件模板 2026-09-11 13:05:10 +08:00
AndyChen1024
b14f883e3e feat(auth): 手机号验证码登录(会话模型 + 登录页 + 导航接入) 2026-09-11 13:05:10 +08:00
AndyChen1024
762e3975ca docs: AGENTS.md 补充三端关联项目说明 2026-09-11 13:05:10 +08:00
AndyChen1024
18f6ddd449 docs: 补充 V4.0 需求规格说明书(小程序+后端) 2026-09-11 13:05:10 +08:00
AndyChen1024
e033c18aca feat: 「计划」未登录自动跳转「我的」 2026-09-09 22:20:01 +08:00
AndyChen1024
84c4bf64b3 feat: 「打卡」未登录自动跳转「我的」 2026-09-09 22:19:47 +08:00
AndyChen1024
0b8bcfe800 feat: 「首页」支持游客模式 2026-09-09 22:19:12 +08:00
AndyChen1024
03410d7ea2 feat: 「我的」页退出登录/登录接入会话状态 2026-09-09 22:16:25 +08:00
AndyChen1024
c41c04ff23 feat: 引入共享会话状态与未登录占位组件 2026-09-09 22:15:46 +08:00
AndyChen1024
c80fb31eb1 feat: 复刻小程序四大主页面与底部导航(UI 优先,本地 mock) 2026-09-09 19:57:47 +08:00
62 changed files with 3951 additions and 72 deletions

4
.gitignore vendored
View File

@@ -8,6 +8,7 @@ gradle/gradle-daemon-jvm.properties
*.iml
.idea/
local.properties
secrets.properties
# OS
.DS_Store
@@ -30,3 +31,6 @@ out/
# Agent skills(安装产生的陈旧副本,规范源见 .agents/skills + skills-lock.json)
agent/
# 临时文件(截图、日志等本地草稿)
tmp/

View File

@@ -2,6 +2,16 @@
「明缘进阶场」的 Android 客户端(`com.mingyuan.android_client`),对接 `wechat-backend` 提供的 auth / checkin / plan / course-vote / survey / feedback / profile API。当前处于**基础建设阶段**:多模块骨架已就绪,各 feature 为空占位,尚未接入真实后端。
## 关联项目
本仓库是「明缘进阶场」三端之一。分析功能/业务时可跨仓库参考对应实现。
| 端 | 仓库地址 | 本地路径 |
|------|------|------|
| Android(本仓库) | https://git.muguaclub.cn/mingyuan/android-client | 当前目录(`android-client`) |
| 后端 | https://git.muguaclub.cn/mingyuan/wechat-backend | `../wechat-backend` |
| 小程序 | https://git.muguaclub.cn/mingyuan/wechat-clock-mini | `../wechat-clock-mini` |
## 快速开始
```bash

View File

@@ -15,9 +15,11 @@ android {
dependencies {
implementation(project(":core:designsystem"))
implementation(project(":core:ui"))
implementation(project(":core:model"))
implementation(project(":core:data"))
implementation(project(":feature:auth"))
implementation(project(":feature:home"))
implementation(project(":feature:checkin"))
implementation(project(":feature:plan"))
implementation(project(":feature:course"))
@@ -25,6 +27,7 @@ dependencies {
implementation(project(":feature:feedback"))
implementation(project(":feature:profile"))
implementation(libs.androidx.compose.material.icons.core)
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.lifecycle.runtime.ktx)

View File

@@ -1,41 +1,140 @@
package com.mingyuan.android_client
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Icon
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.NavigationBarItemDefaults
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import com.mingyuan.android_client.core.designsystem.theme.TabActive
import com.mingyuan.android_client.core.designsystem.theme.TabBackground
import com.mingyuan.android_client.core.designsystem.theme.TabInactive
import com.mingyuan.android_client.feature.auth.AUTH_ROUTE
import com.mingyuan.android_client.feature.auth.AuthScreen
import com.mingyuan.android_client.feature.checkin.CHECKIN_ROUTE
import com.mingyuan.android_client.feature.checkin.CheckinScreen
import com.mingyuan.android_client.feature.course.COURSE_ROUTE
import com.mingyuan.android_client.feature.course.CourseScreen
import com.mingyuan.android_client.feature.feedback.FEEDBACK_ROUTE
import com.mingyuan.android_client.feature.feedback.FeedbackScreen
import com.mingyuan.android_client.feature.home.HOME_ROUTE
import com.mingyuan.android_client.feature.home.HomeScreen
import com.mingyuan.android_client.feature.plan.PLAN_ROUTE
import com.mingyuan.android_client.feature.plan.PLAN_SUBMIT_ROUTE
import com.mingyuan.android_client.feature.plan.PlanScreen
import com.mingyuan.android_client.feature.plan.PlanSubmitScreen
import com.mingyuan.android_client.feature.plan.planSubmitRoute
import com.mingyuan.android_client.feature.profile.PROFILE_ROUTE
import com.mingyuan.android_client.feature.profile.ProfileScreen
import com.mingyuan.android_client.feature.survey.SURVEY_ROUTE
import com.mingyuan.android_client.feature.survey.SurveyScreen
/**
* The single navigation graph for the app. Each feature exposes its own route constant and
* screen composable; this host wires them together (see docs/architecture.md).
* The single navigation graph for the app. Each feature exposes its own route constant and screen
* composable; this host wires them together (see docs/architecture.md).
*/
@Composable
fun AppNavHost() {
val navController = rememberNavController()
val backStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = backStackEntry?.destination?.route
val topLevelRoutes = TopLevelDestination.entries.map { it.route }.toSet()
val navigateToTab: (String) -> Unit = { route ->
navController.navigate(route) {
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
}
Scaffold(
containerColor = TabBackground,
bottomBar = {
if (currentRoute in topLevelRoutes) {
MingyuanBottomBar(
currentRoute = currentRoute,
onNavigate = navigateToTab,
)
}
},
) { innerPadding ->
NavHost(
navController = navController,
startDestination = CHECKIN_ROUTE,
startDestination = HOME_ROUTE,
modifier = Modifier.padding(innerPadding),
) {
composable(AUTH_ROUTE) { AuthScreen() }
composable(CHECKIN_ROUTE) { CheckinScreen() }
composable(PLAN_ROUTE) { PlanScreen() }
composable(COURSE_ROUTE) { CourseScreen() }
composable(SURVEY_ROUTE) { SurveyScreen() }
composable(FEEDBACK_ROUTE) { FeedbackScreen() }
composable(PROFILE_ROUTE) { ProfileScreen() }
composable(HOME_ROUTE) {
HomeScreen(
onNavigateToCheckin = { navigateToTab(CHECKIN_ROUTE) },
onNavigateToPlan = { navigateToTab(PLAN_ROUTE) },
onNavigateToAuth = { navController.navigate(AUTH_ROUTE) },
)
}
composable(CHECKIN_ROUTE) {
CheckinScreen(onNavigateToAuth = { navController.navigate(AUTH_ROUTE) })
}
composable(PLAN_ROUTE) {
PlanScreen(
onNavigateToPlanSubmit = { type, periodKey ->
navController.navigate(planSubmitRoute(type, periodKey))
},
onNavigateToAuth = { navController.navigate(AUTH_ROUTE) },
)
}
composable(
route = PLAN_SUBMIT_ROUTE,
arguments = listOf(
navArgument("planType") { type = NavType.StringType },
navArgument("periodKey") {
type = NavType.StringType
defaultValue = ""
},
),
) {
PlanSubmitScreen(onBack = { navController.popBackStack() })
}
composable(AUTH_ROUTE) {
AuthScreen(onLoggedIn = { navController.popBackStack() })
}
composable(PROFILE_ROUTE) {
ProfileScreen(
onNavigateToAuth = { navController.navigate(AUTH_ROUTE) },
onLoggedOut = { navigateToTab(HOME_ROUTE) },
)
}
}
}
}
@Composable
private fun MingyuanBottomBar(
currentRoute: String?,
onNavigate: (String) -> Unit,
) {
NavigationBar(containerColor = TabBackground) {
TopLevelDestination.entries.forEach { destination ->
NavigationBarItem(
selected = currentRoute == destination.route,
onClick = { onNavigate(destination.route) },
icon = { Icon(destination.icon, contentDescription = destination.label) },
label = { Text(destination.label) },
colors = NavigationBarItemDefaults.colors(
selectedIconColor = TabActive,
selectedTextColor = TabActive,
unselectedIconColor = TabInactive,
unselectedTextColor = TabInactive,
indicatorColor = Color.Transparent,
),
)
}
}
}

View File

@@ -5,7 +5,9 @@ import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import com.mingyuan.android_client.core.designsystem.theme.MingyuanTheme
import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

View File

@@ -0,0 +1,24 @@
package com.mingyuan.android_client
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.DateRange
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Person
import androidx.compose.ui.graphics.vector.ImageVector
import com.mingyuan.android_client.feature.checkin.CHECKIN_ROUTE
import com.mingyuan.android_client.feature.home.HOME_ROUTE
import com.mingyuan.android_client.feature.plan.PLAN_ROUTE
import com.mingyuan.android_client.feature.profile.PROFILE_ROUTE
/** The four top-level tabs shown in the bottom navigation bar. */
enum class TopLevelDestination(
val route: String,
val label: String,
val icon: ImageVector,
) {
HOME(HOME_ROUTE, "首页", Icons.Filled.Home),
CHECKIN(CHECKIN_ROUTE, "打卡", Icons.Filled.CheckCircle),
PLAN(PLAN_ROUTE, "计划", Icons.Filled.DateRange),
PROFILE(PROFILE_ROUTE, "我的", Icons.Filled.Person),
}

View File

@@ -25,8 +25,8 @@ class RootPlugin : Plugin<Project> {
":app" to setOf(
":core:common", ":core:model", ":core:domain", ":core:designsystem",
":core:ui", ":core:data", ":core:network", ":core:database", ":core:datastore",
":feature:auth", ":feature:checkin", ":feature:plan", ":feature:course",
":feature:survey", ":feature:feedback", ":feature:profile",
":feature:auth", ":feature:home", ":feature:checkin", ":feature:plan",
":feature:course", ":feature:survey", ":feature:feedback", ":feature:profile",
),
":core:common" to emptySet(),
":core:model" to emptySet(),
@@ -40,6 +40,7 @@ class RootPlugin : Plugin<Project> {
":core:domain", ":core:model", ":core:network", ":core:database", ":core:datastore",
),
":feature:auth" to featureAllowed,
":feature:home" to featureAllowed,
":feature:checkin" to featureAllowed,
":feature:plan" to featureAllowed,
":feature:course" to featureAllowed,

View File

@@ -13,4 +13,6 @@ dependencies {
implementation(project(":core:network"))
implementation(project(":core:database"))
implementation(project(":core:datastore"))
implementation(libs.kotlinx.coroutines.core)
}

View File

@@ -0,0 +1,56 @@
package com.mingyuan.android_client.core.data.di
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.preferencesDataStoreFile
import com.mingyuan.android_client.core.data.repository.FakeCheckinRepository
import com.mingyuan.android_client.core.data.repository.FakeHomeRepository
import com.mingyuan.android_client.core.data.repository.FakePlanRepository
import com.mingyuan.android_client.core.data.repository.FakeProfileRepository
import com.mingyuan.android_client.core.data.repository.FakeSessionRepository
import com.mingyuan.android_client.core.domain.repository.CheckinRepository
import com.mingyuan.android_client.core.domain.repository.HomeRepository
import com.mingyuan.android_client.core.domain.repository.PlanRepository
import com.mingyuan.android_client.core.domain.repository.ProfileRepository
import com.mingyuan.android_client.core.domain.repository.SessionRepository
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
@Module
@InstallIn(SingletonComponent::class)
abstract class DataModule {
@Binds
@Singleton
abstract fun bindHomeRepository(impl: FakeHomeRepository): HomeRepository
@Binds
@Singleton
abstract fun bindCheckinRepository(impl: FakeCheckinRepository): CheckinRepository
@Binds
@Singleton
abstract fun bindPlanRepository(impl: FakePlanRepository): PlanRepository
@Binds
@Singleton
abstract fun bindProfileRepository(impl: FakeProfileRepository): ProfileRepository
@Binds
@Singleton
abstract fun bindSessionRepository(impl: FakeSessionRepository): SessionRepository
companion object {
@Provides
@Singleton
fun provideSessionDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
PreferenceDataStoreFactory.create { context.preferencesDataStoreFile("session") }
}
}

View File

@@ -0,0 +1,63 @@
package com.mingyuan.android_client.core.data.repository
import com.mingyuan.android_client.core.domain.repository.CheckinRepository
import com.mingyuan.android_client.core.model.CheckinRecord
import com.mingyuan.android_client.core.model.CheckinStatus
import com.mingyuan.android_client.core.model.GroupMember
import com.mingyuan.android_client.core.model.PersonalCheckinRecord
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.update
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class FakeCheckinRepository @Inject constructor() : CheckinRepository {
private val record = MutableStateFlow(seedCheckin())
override fun observeCheckin(): Flow<CheckinRecord> = flow {
delay(250)
emitAll(record)
}
override suspend fun submitCheckin(status: CheckinStatus) {
delay(300)
record.update { current ->
current.copy(
checked = true,
status = status,
groupMembers = current.groupMembers.map { member ->
if (member.id == "me") {
member.copy(checkedIn = true, checkinTime = "现在", status = status)
} else {
member
}
},
)
}
}
private fun seedCheckin() = CheckinRecord(
classDate = "2026-09-09",
weekday = "周三",
dayNum = 9,
checked = false,
status = null,
groupName = "进阶一组",
groupMembers = listOf(
GroupMember("m1", "张三", true, "19:02", CheckinStatus.NORMAL),
GroupMember("m2", "李四", true, "19:30", CheckinStatus.LATE),
GroupMember("me", "我", false, null, null),
GroupMember("m3", "王五", true, "09:10", CheckinStatus.LEAVE),
GroupMember("m4", "赵六", false, null, null),
),
personalHistory = listOf(
PersonalCheckinRecord("2026-09-06", CheckinStatus.NORMAL, "19:05"),
PersonalCheckinRecord("2026-09-02", CheckinStatus.LATE, "19:32"),
),
)
}

View File

@@ -0,0 +1,66 @@
package com.mingyuan.android_client.core.data.repository
import com.mingyuan.android_client.core.domain.repository.HomeRepository
import com.mingyuan.android_client.core.model.HomeData
import com.mingyuan.android_client.core.model.HomeModule
import com.mingyuan.android_client.core.model.IntroCard
import com.mingyuan.android_client.core.model.ModuleDestination
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class FakeHomeRepository @Inject constructor() : HomeRepository {
override fun observeHome(): Flow<HomeData> = flow {
delay(250)
emit(seedHome())
}
override fun observeGuestHome(): Flow<HomeData> = flow {
delay(250)
emit(seedGuestHome())
}
private fun seedHome() = HomeData(
greeting = "晚上好",
displayName = "张三",
encouragement = "坚持每周打卡,与小组共同进步。",
modules = modules(),
intros = intros(),
)
private fun seedGuestHome() = HomeData(
greeting = "欢迎来到明缘云道场",
displayName = "游客",
encouragement = "登录后即可打卡、提交计划",
modules = modules(),
intros = intros(),
)
private fun modules() = listOf(
HomeModule("checkin", "📋", "打卡介绍", "每日打卡记录", ModuleDestination.CHECKIN),
HomeModule("plan", "📝", "计划介绍", "周/月/年计划", ModuleDestination.PLAN),
HomeModule("about", "ℹ️", "关于我们", "了解明缘云道场", ModuleDestination.ABOUT),
)
private fun intros() = listOf(
IntroCard(
"📚 学习成长工具",
"明缘云道场是一款专注于个人成长的效率工具,通过每周打卡与计划管理,帮助你养成良好的学习习惯,实现持续进步。好好学习,天天向上。",
),
IntroCard(
"✅ 周打卡",
"每周打卡,支持多种状态记录(正常、迟到、请假、早退、不参加讨论等)。查看小组打卡情况,与同学们互相监督、共同进步。",
),
IntroCard(
"📝 计划管理",
"提供周计划、月计划、年计划三级管理,支持文字与图片上传。清晰追踪每个周期的目标完成情况,让成长有迹可循。",
),
IntroCard(
"👥 小组共学",
"加入学习小组,查看组员的打卡与计划完成情况。在集体的氛围中保持学习动力,与志同道合的伙伴一起成长。",
),
)
}

View File

@@ -0,0 +1,96 @@
package com.mingyuan.android_client.core.data.repository
import com.mingyuan.android_client.core.domain.repository.PlanRepository
import com.mingyuan.android_client.core.model.MyPlanRecord
import com.mingyuan.android_client.core.model.PlanGroup
import com.mingyuan.android_client.core.model.PlanMember
import com.mingyuan.android_client.core.model.PlanRecord
import com.mingyuan.android_client.core.model.PlanSubmitData
import com.mingyuan.android_client.core.model.PlanType
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flow
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class FakePlanRepository @Inject constructor() : PlanRepository {
private val plans = PlanType.entries.associateWith { type -> MutableStateFlow(seedPlan(type)) }
private val submits = mutableMapOf<String, MutableStateFlow<PlanSubmitData>>()
override fun observePlan(type: PlanType): Flow<PlanRecord> = flow {
delay(250)
emitAll(plans.getValue(type))
}
override fun observePlanSubmit(type: PlanType, periodKey: String): Flow<PlanSubmitData> = flow {
delay(250)
emitAll(submitState(type, periodKey))
}
override suspend fun savePlan(
type: PlanType,
periodKey: String,
content: String,
attachments: List<String>,
) {
delay(300)
val submit = submitState(type, periodKey)
submit.value = submit.value.copy(content = content, attachments = attachments, checked = true)
val plan = plans.getValue(type)
plan.value = plan.value.copy(
myChecked = true,
myRecord = MyPlanRecord("已完成", "现在", content, attachments),
)
}
private fun submitState(type: PlanType, periodKey: String): MutableStateFlow<PlanSubmitData> =
submits.getOrPut("${type.name}:$periodKey") { MutableStateFlow(seedSubmit(type)) }
private fun seedPlan(type: PlanType) = PlanRecord(
type = type,
periodKey = periodKeyFor(type),
canSubmit = true,
message = if (type == PlanType.WEEKLY) "本周计划提交截止:周日晚 23:59" else null,
myChecked = type == PlanType.WEEKLY,
myRecord = if (type == PlanType.WEEKLY) {
MyPlanRecord("已完成", "今天 18:30", "本周完成两章学习,整理笔记并复习错题。", listOf("图1", "图2"))
} else {
null
},
groups = listOf(
PlanGroup("g1", "进阶一组", 4, 6, memberSeed()),
PlanGroup("g2", "进阶二组", 5, 5, memberSeed()),
PlanGroup("g3", "进阶三组", 2, 7, memberSeed()),
),
unassignedCount = 2,
)
private fun seedSubmit(type: PlanType) = PlanSubmitData(
content = if (type == PlanType.WEEKLY) "本周完成两章学习,整理笔记并复习错题。" else "",
attachments = if (type == PlanType.WEEKLY) listOf("图1", "图2") else emptyList(),
checked = type == PlanType.WEEKLY,
canSubmit = true,
message = null,
history = if (type == PlanType.WEEKLY) {
emptyList()
} else {
listOf(MyPlanRecord("已完成", "2026-08 完成", "上月计划内容……", emptyList()))
},
)
private fun periodKeyFor(type: PlanType): String = when (type) {
PlanType.WEEKLY -> "2026 第 37 周"
PlanType.MONTHLY -> "2026 年 9 月"
PlanType.YEARLY -> "2026 年"
}
private fun memberSeed() = listOf(
PlanMember("p1", "张三", true),
PlanMember("p2", "李四", true),
PlanMember("p3", "王五", false),
)
}

View File

@@ -0,0 +1,38 @@
package com.mingyuan.android_client.core.data.repository
import com.mingyuan.android_client.core.domain.repository.ProfileRepository
import com.mingyuan.android_client.core.model.ClassGroup
import com.mingyuan.android_client.core.model.UserProfile
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flow
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class FakeProfileRepository @Inject constructor() : ProfileRepository {
private val profile = MutableStateFlow(seedProfile())
override fun observeProfile(): Flow<UserProfile> = flow {
delay(250)
emitAll(profile)
}
private fun seedProfile() = UserProfile(
displayName = "张三",
name = "张三",
studentId = "20260001",
phone = "138****1234",
role = "成员",
group = "进阶一组",
loginType = "手机号",
avatarUrl = null,
classGroups = listOf(
ClassGroup("2026 秋季", "进阶一组", "成员"),
ClassGroup("2026 春季", "进阶二组", "组长"),
),
)
}

View File

@@ -0,0 +1,71 @@
package com.mingyuan.android_client.core.data.repository
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import com.mingyuan.android_client.core.domain.repository.LoginResult
import com.mingyuan.android_client.core.domain.repository.SessionRepository
import com.mingyuan.android_client.core.domain.repository.SmsCodeResult
import com.mingyuan.android_client.core.model.Session
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import javax.inject.Inject
import javax.inject.Singleton
/**
* Local-mock session implementation. It simulates the "phone + SMS code" login flow and persists
* the token via DataStore so the login survives process restarts (mirroring the mini-program's
* `wx.getStorageSync('token')`). Swap this for a network-backed implementation when the backend
* endpoints are ready.
*
* Mock credentials: phone `13812341234`, code `123456`.
*/
@Singleton
class FakeSessionRepository @Inject constructor(
private val dataStore: DataStore<Preferences>,
) : SessionRepository {
private companion object {
val TOKEN_KEY = stringPreferencesKey("token")
const val MOCK_SMS_CODE = "123456"
val PHONE_PATTERN = Regex("^1\\d{10}$")
// Mirrors the backend's "phone must be pre-provisioned" constraint.
val PHONE_WHITELIST = setOf("13812341234")
}
private var pendingCode: String? = null
private var pendingPhone: String? = null
override val session: Flow<Session?> = dataStore.data
.map { prefs -> prefs[TOKEN_KEY]?.let(::Session) }
override suspend fun requestSmsCode(phone: String): SmsCodeResult {
if (!PHONE_PATTERN.matches(phone)) {
return SmsCodeResult.Failure("请输入正确的手机号")
}
delay(500)
pendingPhone = phone
pendingCode = MOCK_SMS_CODE
return SmsCodeResult.Success
}
override suspend fun login(phone: String, code: String): LoginResult {
if (phone != pendingPhone || code != pendingCode) {
return LoginResult.Failure("验证码错误")
}
if (phone !in PHONE_WHITELIST) {
return LoginResult.Failure("手机号未匹配,无法登录")
}
delay(500)
dataStore.edit { it[TOKEN_KEY] = "mock-token-$phone" }
pendingPhone = null
pendingCode = null
return LoginResult.Success
}
override suspend fun logout() {
dataStore.edit { it.remove(TOKEN_KEY) }
}
}

View File

@@ -8,5 +8,5 @@ android {
dependencies {
implementation(project(":core:model"))
implementation(libs.androidx.datastore.preferences)
api(libs.androidx.datastore.preferences)
}

View File

@@ -0,0 +1,33 @@
package com.mingyuan.android_client.core.designsystem.component
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.mingyuan.android_client.core.designsystem.theme.AmberGradient
import com.mingyuan.android_client.core.designsystem.theme.Divider
@Composable
fun AmberProgressBar(
progress: Float,
modifier: Modifier = Modifier,
) {
val fraction = progress.coerceIn(0f, 1f)
Box(
modifier = modifier
.fillMaxWidth()
.height(8.dp)
.background(Divider, RoundedCornerShape(4.dp)),
) {
Box(
modifier = Modifier
.fillMaxWidth(fraction)
.height(8.dp)
.background(AmberGradient, RoundedCornerShape(4.dp)),
)
}
}

View File

@@ -0,0 +1,37 @@
package com.mingyuan.android_client.core.designsystem.component
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.mingyuan.android_client.core.designsystem.theme.BrandAmber
@Composable
fun Avatar(
initial: String,
modifier: Modifier = Modifier,
size: Dp = 40.dp,
backgroundColor: Color = BrandAmber,
textColor: Color = Color.White,
) {
Box(
modifier = modifier.size(size).background(backgroundColor, CircleShape),
contentAlignment = Alignment.Center,
) {
Text(
text = initial.take(1),
color = textColor,
fontSize = (size.value * 0.42f).sp,
fontWeight = FontWeight.Bold,
)
}
}

View File

@@ -0,0 +1,36 @@
package com.mingyuan.android_client.core.designsystem.component
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.mingyuan.android_client.core.designsystem.theme.MutedText
import com.mingyuan.android_client.core.designsystem.theme.PrimaryText
@Composable
fun LabeledRow(
label: String,
value: String,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier.fillMaxWidth().padding(vertical = 8.dp),
) {
Text(label, color = MutedText, fontSize = 13.sp)
Spacer(Modifier.width(16.dp))
Text(
text = value,
color = PrimaryText,
fontSize = 14.sp,
textAlign = TextAlign.End,
modifier = Modifier.weight(1f),
)
}
}

View File

@@ -0,0 +1,74 @@
package com.mingyuan.android_client.core.designsystem.component
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.mingyuan.android_client.core.designsystem.theme.AmberGradient
import com.mingyuan.android_client.core.designsystem.theme.BrandAmber
import com.mingyuan.android_client.core.designsystem.theme.Danger
import com.mingyuan.android_client.core.designsystem.theme.Disabled
enum class ButtonVariant { PRIMARY, DANGER, OUTLINE }
@Composable
fun MingyuanButton(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
variant: ButtonVariant = ButtonVariant.PRIMARY,
enabled: Boolean = true,
fullWidth: Boolean = false,
) {
val shape = RoundedCornerShape(50)
val backgroundModifier = when {
!enabled -> Modifier.background(Disabled, shape)
variant == ButtonVariant.PRIMARY -> Modifier.background(AmberGradient, shape)
else -> Modifier.background(Color.White, shape)
}
val borderModifier = when {
!enabled -> Modifier
variant == ButtonVariant.DANGER -> Modifier.border(1.dp, Danger, shape)
variant == ButtonVariant.OUTLINE -> Modifier.border(1.dp, BrandAmber, shape)
else -> Modifier
}
val textColor = when {
!enabled -> Color.White
variant == ButtonVariant.PRIMARY -> Color.White
variant == ButtonVariant.DANGER -> Danger
else -> BrandAmber
}
Box(
modifier = modifier
.then(if (fullWidth) Modifier.fillMaxWidth() else Modifier)
.clip(shape)
.then(backgroundModifier)
.then(borderModifier)
.clickable(enabled = enabled, onClick = onClick)
.padding(horizontal = 24.dp, vertical = 12.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = text,
color = textColor,
fontSize = 15.sp,
fontWeight = FontWeight.SemiBold,
)
}
}

View File

@@ -0,0 +1,35 @@
package com.mingyuan.android_client.core.designsystem.component
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.mingyuan.android_client.core.designsystem.theme.SoftShadow
@Composable
fun SectionCard(
modifier: Modifier = Modifier,
cornerRadius: Int = 16,
backgroundColor: Color = Color.White,
contentPadding: Int = 16,
content: @Composable ColumnScope.() -> Unit,
) {
Column(
modifier = modifier
.shadow(
elevation = 4.dp,
shape = RoundedCornerShape(cornerRadius.dp),
ambientColor = SoftShadow,
spotColor = SoftShadow,
)
.background(backgroundColor, RoundedCornerShape(cornerRadius.dp))
.padding(contentPadding.dp),
content = content,
)
}

View File

@@ -0,0 +1,54 @@
package com.mingyuan.android_client.core.designsystem.component
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.mingyuan.android_client.core.designsystem.theme.BrandAmber
import com.mingyuan.android_client.core.designsystem.theme.SecondaryText
import com.mingyuan.android_client.core.designsystem.theme.SegmentedTrack
@Composable
fun SegmentedControl(
labels: List<String>,
selectedIndex: Int,
onSelect: (Int) -> Unit,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier
.background(SegmentedTrack, RoundedCornerShape(50))
.padding(4.dp),
) {
labels.forEachIndexed { index, label ->
val selected = index == selectedIndex
Box(
modifier = Modifier
.weight(1f)
.clip(RoundedCornerShape(50))
.background(if (selected) Color.White else Color.Transparent)
.clickable { onSelect(index) }
.padding(vertical = 8.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = label,
color = if (selected) BrandAmber else SecondaryText,
fontSize = 14.sp,
fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal,
)
}
}
}
}

View File

@@ -0,0 +1,39 @@
package com.mingyuan.android_client.core.designsystem.component
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.unit.dp
import com.mingyuan.android_client.core.designsystem.theme.SkeletonColor
@Composable
fun SkeletonBox(
modifier: Modifier = Modifier,
cornerRadius: Int = 12,
) {
val transition = rememberInfiniteTransition(label = "skeleton")
val alpha by transition.animateFloat(
initialValue = 0.4f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 700, easing = LinearEasing),
repeatMode = RepeatMode.Reverse,
),
label = "skeleton-alpha",
)
Box(
modifier = modifier
.graphicsLayer { this.alpha = alpha }
.background(SkeletonColor, RoundedCornerShape(cornerRadius.dp)),
)
}

View File

@@ -0,0 +1,97 @@
package com.mingyuan.android_client.core.designsystem.component
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.mingyuan.android_client.core.designsystem.theme.Danger
import com.mingyuan.android_client.core.designsystem.theme.ErrorBackground
import com.mingyuan.android_client.core.designsystem.theme.FaintText
import com.mingyuan.android_client.core.designsystem.theme.MutedText
import com.mingyuan.android_client.core.designsystem.theme.NeutralChip
import com.mingyuan.android_client.core.designsystem.theme.PrimaryText
@Composable
fun EmptyState(
emoji: String,
message: String,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier.fillMaxWidth().padding(vertical = 32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Box(
modifier = Modifier.size(72.dp).background(NeutralChip, CircleShape),
contentAlignment = Alignment.Center,
) {
Text(emoji, fontSize = 32.sp)
}
Spacer(Modifier.height(12.dp))
Text(message, color = FaintText, fontSize = 14.sp)
}
}
@Composable
fun ErrorState(
message: String,
onRetry: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier.fillMaxWidth().padding(vertical = 32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Box(
modifier = Modifier.size(72.dp).background(ErrorBackground, CircleShape),
contentAlignment = Alignment.Center,
) {
Text("!", color = Danger, fontSize = 32.sp, fontWeight = FontWeight.Bold)
}
Spacer(Modifier.height(12.dp))
Text(message, color = MutedText, fontSize = 14.sp)
Spacer(Modifier.height(16.dp))
MingyuanButton(text = "重新加载", onClick = onRetry)
}
}
@Composable
fun LoggedOutState(
onLogin: (() -> Unit)? = null,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Box(
modifier = Modifier.size(72.dp).background(NeutralChip, CircleShape),
contentAlignment = Alignment.Center,
) {
Text("🔒", fontSize = 32.sp)
}
Spacer(Modifier.height(12.dp))
Text("未登录", color = PrimaryText, fontSize = 15.sp, fontWeight = FontWeight.SemiBold)
Spacer(Modifier.height(4.dp))
Text("登录后即可查看", color = FaintText, fontSize = 13.sp)
if (onLogin != null) {
Spacer(Modifier.height(16.dp))
MingyuanButton(text = "登录", onClick = onLogin)
}
}
}

View File

@@ -2,10 +2,48 @@ package com.mingyuan.android_client.core.designsystem.theme
import androidx.compose.ui.graphics.Color
val Purple80 = Color(0xFFD0BCFF)
val PurpleGrey80 = Color(0xFFCCC2DC)
val Pink80 = Color(0xFFEFB8C8)
// Brand palette — ported from the WeChat mini-program design tokens.
val PageBackground = Color(0xFFFDFBF7)
val PlanBackground = Color(0xFFF6F2EC)
val Purple40 = Color(0xFF6650A4)
val PurpleGrey40 = Color(0xFF625B71)
val Pink40 = Color(0xFF7D5260)
val PrimaryText = Color(0xFF2C1810)
val SecondaryText = Color(0xFF5A4A3A)
val MutedText = Color(0xFF8B7A6A)
val MutedLightText = Color(0xFFA99A89)
val FaintText = Color(0xFFB6A996)
val BrandAmber = Color(0xFFC67B30)
val LightAmber = Color(0xFFE3B37C)
val AmberMid = Color(0xFFE4A05E)
val AmberBright = Color(0xFFEFA45D)
val HeroGradientStart = Color(0xFF2C1810)
val HeroGradientMid = Color(0xFF4A2B1D)
val HeroGradientEnd = Color(0xFF6B3F24)
val StatusNormal = Color(0xFF2E7D32)
val StatusLate = Color(0xFFC67B30)
val StatusLeave = Color(0xFF3A7BD5)
val StatusEarlyLeave = Color(0xFF8B7A6A)
val StatusAbsent = Color(0xFFC0392B)
val Danger = Color(0xFFC0392B)
val ErrorBackground = Color(0xFFFDEAEA)
val SuccessBackground = Color(0xFFE8F4EB)
val WarningBackground = Color(0xFFFFF3E6)
val WarningBorder = Color(0xFFF7D9B9)
val NeutralChip = Color(0xFFF6EFE6)
val SegmentedTrack = Color(0xFFEFE7DC)
val Divider = Color(0xFFF2EDE7)
val PillIdleBorder = Color(0xFFD8C7B4)
val TextareaBorder = Color(0xFFE4D9CB)
val Disabled = Color(0xFFD8C7B4)
val TabBackground = Color(0xFFFFFFFF)
val TabInactive = Color(0xFF5A4A3A)
val TabActive = Color(0xFFC67B30)
val SoftShadow = Color(0x0D2C1810)
val SkeletonColor = Color(0xFFEFE6D9)
val SkeletonHighlight = Color(0xFFFAF4EC)

View File

@@ -0,0 +1,16 @@
package com.mingyuan.android_client.core.designsystem.theme
import androidx.compose.ui.graphics.Brush
// Brand gradients (135deg, top-left → bottom-right, matching the mini-program).
val HeroGradient = Brush.linearGradient(
colors = listOf(HeroGradientStart, HeroGradientMid, HeroGradientEnd),
)
val AmberGradient = Brush.linearGradient(
colors = listOf(BrandAmber, AmberMid),
)
val AvatarGradient = Brush.linearGradient(
colors = listOf(BrandAmber, LightAmber),
)

View File

@@ -1,49 +1,59 @@
package com.mingyuan.android_client.core.designsystem.theme
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
private val DarkColorScheme = darkColorScheme(
primary = Purple80,
secondary = PurpleGrey80,
tertiary = Pink80,
)
import androidx.compose.ui.graphics.Color
private val LightColorScheme = lightColorScheme(
primary = Purple40,
secondary = PurpleGrey40,
tertiary = Pink40,
primary = BrandAmber,
onPrimary = Color.White,
primaryContainer = LightAmber,
onPrimaryContainer = PrimaryText,
secondary = AmberMid,
onSecondary = Color.White,
tertiary = StatusLeave,
background = PageBackground,
onBackground = PrimaryText,
surface = Color.White,
onSurface = PrimaryText,
surfaceVariant = NeutralChip,
onSurfaceVariant = MutedText,
error = Danger,
onError = Color.White,
outline = Divider,
outlineVariant = PillIdleBorder,
)
private val DarkColorScheme = darkColorScheme(
primary = LightAmber,
onPrimary = PrimaryText,
primaryContainer = HeroGradientMid,
onPrimaryContainer = LightAmber,
background = HeroGradientStart,
onBackground = Color(0xFFFDFBF7),
surface = Color(0xFF2A1E18),
onSurface = Color(0xFFFDFBF7),
surfaceVariant = Color(0xFF3A2A20),
onSurfaceVariant = MutedLightText,
error = Color(0xFFE5736C),
)
/**
* The app's single Material theme. All UI entry points wrap their content in [MingyuanTheme].
*
* Dynamic color is deliberately disabled so the brand palette always renders as designed,
* regardless of the device's wallpaper-based Material You scheme.
*/
@Composable
fun MingyuanTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
// Dynamic color is available on Android 12+.
dynamicColor: Boolean = true,
content: @Composable () -> Unit,
) {
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> DarkColorScheme
else -> LightColorScheme
}
MaterialTheme(
colorScheme = colorScheme,
colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme,
typography = Typography,
content = content,
)

View File

@@ -3,6 +3,7 @@ plugins {
}
dependencies {
implementation(project(":core:model"))
implementation(libs.kotlinx.coroutines.core)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.truth)

View File

@@ -0,0 +1,11 @@
package com.mingyuan.android_client.core.domain.repository
import com.mingyuan.android_client.core.model.CheckinRecord
import com.mingyuan.android_client.core.model.CheckinStatus
import kotlinx.coroutines.flow.Flow
interface CheckinRepository {
fun observeCheckin(): Flow<CheckinRecord>
suspend fun submitCheckin(status: CheckinStatus)
}

View File

@@ -0,0 +1,10 @@
package com.mingyuan.android_client.core.domain.repository
import com.mingyuan.android_client.core.model.HomeData
import kotlinx.coroutines.flow.Flow
interface HomeRepository {
fun observeHome(): Flow<HomeData>
fun observeGuestHome(): Flow<HomeData>
}

View File

@@ -0,0 +1,19 @@
package com.mingyuan.android_client.core.domain.repository
import com.mingyuan.android_client.core.model.PlanRecord
import com.mingyuan.android_client.core.model.PlanSubmitData
import com.mingyuan.android_client.core.model.PlanType
import kotlinx.coroutines.flow.Flow
interface PlanRepository {
fun observePlan(type: PlanType): Flow<PlanRecord>
fun observePlanSubmit(type: PlanType, periodKey: String): Flow<PlanSubmitData>
suspend fun savePlan(
type: PlanType,
periodKey: String,
content: String,
attachments: List<String>,
)
}

View File

@@ -0,0 +1,8 @@
package com.mingyuan.android_client.core.domain.repository
import com.mingyuan.android_client.core.model.UserProfile
import kotlinx.coroutines.flow.Flow
interface ProfileRepository {
fun observeProfile(): Flow<UserProfile>
}

View File

@@ -0,0 +1,30 @@
package com.mingyuan.android_client.core.domain.repository
import com.mingyuan.android_client.core.model.Session
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
sealed interface SmsCodeResult {
data object Success : SmsCodeResult
data class Failure(val message: String) : SmsCodeResult
}
sealed interface LoginResult {
data object Success : LoginResult
data class Failure(val message: String) : LoginResult
}
interface SessionRepository {
/** The current session, or `null` when logged out. */
val session: Flow<Session?>
/** Derived convenience kept for the existing feature ViewModels (home/checkin/plan/profile). */
val isLoggedIn: Flow<Boolean>
get() = session.map { it != null }
suspend fun requestSmsCode(phone: String): SmsCodeResult
suspend fun login(phone: String, code: String): LoginResult
suspend fun logout()
}

View File

@@ -0,0 +1,11 @@
package com.mingyuan.android_client.core.model
/**
* A logged-in session. Deliberately minimal for now: the token is the only thing needed to decide
* whether the user is logged in, and the user profile is still provided by [ProfileRepository].
* When the real backend lands, this grows a `user: AuthUser` field and the local data source grows
* a user cache.
*/
data class Session(
val token: String,
)

View File

@@ -0,0 +1,34 @@
package com.mingyuan.android_client.core.model
enum class CheckinStatus(val label: String) {
NORMAL("正常"),
LATE("迟到"),
LEAVE("请假"),
ABSENT("不参加讨论"),
EARLY_LEAVE("早退"),
}
data class GroupMember(
val id: String,
val name: String,
val checkedIn: Boolean,
val checkinTime: String?,
val status: CheckinStatus?,
)
data class PersonalCheckinRecord(
val date: String,
val status: CheckinStatus,
val time: String,
)
data class CheckinRecord(
val classDate: String,
val weekday: String,
val dayNum: Int,
val checked: Boolean,
val status: CheckinStatus?,
val groupName: String,
val groupMembers: List<GroupMember>,
val personalHistory: List<PersonalCheckinRecord>,
)

View File

@@ -0,0 +1,28 @@
package com.mingyuan.android_client.core.model
enum class ModuleDestination {
CHECKIN,
PLAN,
ABOUT,
}
data class HomeModule(
val id: String,
val emoji: String,
val title: String,
val description: String,
val destination: ModuleDestination,
)
data class IntroCard(
val title: String,
val body: String,
)
data class HomeData(
val greeting: String,
val displayName: String,
val encouragement: String,
val modules: List<HomeModule>,
val intros: List<IntroCard>,
)

View File

@@ -0,0 +1,48 @@
package com.mingyuan.android_client.core.model
enum class PlanType(val label: String) {
WEEKLY("周计划"),
MONTHLY("月计划"),
YEARLY("年计划"),
}
data class MyPlanRecord(
val status: String,
val submittedTime: String,
val content: String,
val attachments: List<String>,
)
data class PlanMember(
val id: String,
val name: String,
val filled: Boolean,
)
data class PlanGroup(
val id: String,
val name: String,
val checkedCount: Int,
val memberCount: Int,
val members: List<PlanMember>,
)
data class PlanRecord(
val type: PlanType,
val periodKey: String,
val canSubmit: Boolean,
val message: String?,
val myChecked: Boolean,
val myRecord: MyPlanRecord?,
val groups: List<PlanGroup>,
val unassignedCount: Int,
)
data class PlanSubmitData(
val content: String,
val attachments: List<String>,
val checked: Boolean,
val canSubmit: Boolean,
val message: String?,
val history: List<MyPlanRecord>,
)

View File

@@ -0,0 +1,19 @@
package com.mingyuan.android_client.core.model
data class ClassGroup(
val period: String,
val group: String,
val role: String,
)
data class UserProfile(
val displayName: String,
val name: String,
val studentId: String,
val phone: String?,
val role: String,
val group: String?,
val loginType: String,
val avatarUrl: String?,
val classGroups: List<ClassGroup>,
)

View File

@@ -0,0 +1,37 @@
package com.mingyuan.android_client.core.ui.status
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.mingyuan.android_client.core.model.CheckinStatus
@Composable
fun StatusBadge(
status: CheckinStatus,
modifier: Modifier = Modifier,
) {
val color = status.statusColor()
Row(
modifier = modifier
.background(color.copy(alpha = 0.12f), RoundedCornerShape(50))
.padding(horizontal = 8.dp, vertical = 3.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(Modifier.size(6.dp).background(color, CircleShape))
Spacer(Modifier.width(4.dp))
Text(status.label, color = color, fontSize = 12.sp, fontWeight = FontWeight.SemiBold)
}
}

View File

@@ -0,0 +1,17 @@
package com.mingyuan.android_client.core.ui.status
import androidx.compose.ui.graphics.Color
import com.mingyuan.android_client.core.designsystem.theme.StatusAbsent
import com.mingyuan.android_client.core.designsystem.theme.StatusEarlyLeave
import com.mingyuan.android_client.core.designsystem.theme.StatusLate
import com.mingyuan.android_client.core.designsystem.theme.StatusLeave
import com.mingyuan.android_client.core.designsystem.theme.StatusNormal
import com.mingyuan.android_client.core.model.CheckinStatus
fun CheckinStatus.statusColor(): Color = when (this) {
CheckinStatus.NORMAL -> StatusNormal
CheckinStatus.LATE -> StatusLate
CheckinStatus.LEAVE -> StatusLeave
CheckinStatus.ABSENT -> StatusAbsent
CheckinStatus.EARLY_LEAVE -> StatusEarlyLeave
}

View File

@@ -0,0 +1,60 @@
package com.mingyuan.android_client.core.ui.status
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.mingyuan.android_client.core.designsystem.theme.PillIdleBorder
import com.mingyuan.android_client.core.designsystem.theme.SecondaryText
import com.mingyuan.android_client.core.model.CheckinStatus
@Composable
fun StatusPill(
status: CheckinStatus,
selected: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val color = status.statusColor()
val shape = RoundedCornerShape(50)
val background = if (selected) color else Color.White
val textColor = if (selected) Color.White else SecondaryText
val borderColor = if (selected) color else PillIdleBorder
val dotColor = if (selected) Color.White else color
Row(
modifier = modifier
.shadow(
elevation = if (selected) 6.dp else 0.dp,
shape = shape,
spotColor = color.copy(alpha = 0.22f),
ambientColor = color.copy(alpha = 0.22f),
)
.background(background, shape)
.border(1.dp, borderColor, shape)
.clickable(onClick = onClick)
.padding(horizontal = 14.dp, vertical = 9.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(Modifier.size(8.dp).background(dotColor, CircleShape))
Spacer(Modifier.width(6.dp))
Text(status.label, color = textColor, fontSize = 13.sp, fontWeight = FontWeight.SemiBold)
}
}

View File

@@ -18,7 +18,7 @@
| `core:datastore` | android library | DataStore 偏好存储 |
| `core:designsystem` | android library + compose | 主题 / 颜色 / 字体 / 通用组件 |
| `core:ui` | android library + compose + hilt | 共享 Compose UI 与导航辅助 |
| `feature:*` | feature | 各业务功能(auth/checkin/plan/course/survey/feedback/profile) |
| `feature:*` | feature | 各业务功能(auth/home/checkin/plan/course/survey/feedback/profile) |
## 分层与依赖方向

View File

@@ -0,0 +1,657 @@
# 明缘云道场小程序 + 后端 API · 需求规格说明书 V4.0
> 基于 `miniprogram/`(微信小程序前端)和 `server/`(FastAPI 后端)实际代码反向整理。
> 与 V2.0/V3.0(涵盖 Web 主站 + 学员网站)平行存在;本文档 **仅覆盖这两个子系统**。
>
> · 适用范围:`miniprogram/`、`server/`、`server/migrations/`
> · 反向工程日期:2026-09-10
> · 反向基线:`git` 工作树(HEAD = 当前)
---
## 目录
1. 文档信息
2. 项目概述
3. 用户角色与权限
4. 术语表
5. 小程序前端(miniprogram/)
6. 后端 API(server/)
7. 数据模型
8. 部署与配置
9. 安全与合规
10. 非功能性需求
11. 错误约定
12. 已知问题与 TODO
---
## 1. 文档信息
| 项 | 内容 |
| ----- | --------------------------------------------------- |
| 软件名称 | 明缘云道场小程序(前端)+ 明缘进阶场 API(后端) |
| 版本 | V1.0 / V4.0 文档 |
| 平台 | 前端:微信小程序 · 后端:Linux + Python 3.9 + FastAPI |
| 关键依赖 | 微信开发者工具 · PyMySQL + DBUtils · httpx · python-dotenv |
| 客户端入口 | 微信内搜索 / 扫码进入「明缘云道场」 |
| 服务端入口 | uvicorn `server.main:app` 监听 `:8000` |
| 文档目的 | 把代码事实定型为可验收的需求规格 |
---
## 2. 项目概述
### 2.1 子系统定位
| 子系统 | 代码路径 | 角色 |
| ------ | -------------- | ---------------------------------------------- |
| 小程序前端 | `miniprogram/` | 用户交互入口(打卡 / 计划 / 个人),本地无状态,所有数据走后端 API |
| 后端 API | `server/` | 鉴权、业务逻辑、MySQL 读写、文件存储、WebView 桥接 |
| 微信生态 | 微信开放接口 | `wx.login` 换取 openid、`getuserphonenumber` 绑手机号 |
### 2.2 业务目标
为「明缘进阶场」体系的成员提供基于微信的 **轻量化学习管理工具**,核心是:
1. **每周打卡**(周日上课日)
2. **周 / 月 / 年计划** 三级管理
3. **小组统计**(学员互看,管理员全览)
4. **个人主页**(资料、头像、登录方式)
5. 内嵌 **H5 复用既有 Web 复盘系统**(通过 WebView + cookie 桥接)
### 2.3 不在范围内(明确)
- 不在前端代码内的子系统(Web 主站 `xinhuo/19qi/18qi/`、学员站)——见 V3.0
- 不做支付、不做 IM、不做推送(小程序代码中无相关痕迹)
- 不做服务端渲染 SSR
---
## 3. 用户角色与权限
### 3.1 角色矩阵
代码侧只识别两种角色(`auth_users.role` 字段):
| 角色键 | 中文 | 判定方式 | 用例 |
| -------------------------------------------------------- | -------- | ------------------------------------------------ | -------------------- |
| `teacher` | 管理员 / 老师 | `(user.role or '').strip().lower() == 'teacher'` | 看所有小组、看未分组人数、看全部打卡统计 |
| 其他(含 `student`、`member`、`leader`、`group_leader`、`admin`) | 成员 / 组长 | 非 teacher 走学员分支 | 只看自己所在小组的数据 |
> 注:`ROLE_LABELS`(`pages/profile/profile.js`)将 `student/member → 成员`、`teacher/admin → 管理员`、`leader/group_leader → 组长`,但后端权限只认 `teacher`,其他角色后端一律视作学员。
### 3.2 权限矩阵
| 接口前缀 | 学员 | 老师 |
| ------------------------------------ | ---------------- | ----------------- |
| `/api/v1/auth/*` | ✅ 自助 | ✅ 同左 |
| `/api/v1/profile` | ✅ 自己 | ✅ 自己 |
| `/api/v1/profile/avatar` | ✅ 自己 | ✅ 自己 |
| `/api/v1/checkin/weeks` | ✅ | ✅ |
| `/api/v1/checkin/groups` | ✅(仅看自己小组) | ✅(看全部 + 未分组数) |
| `/api/v1/checkin/group` | 仅自己小组(传他组 → 403) | 任意小组(不传看自己) |
| `/api/v1/checkin` POST | ✅ 自己 | ✅ 自己 |
| `/api/v1/checkin/list` | ✅ 自己历史 | ✅ 自己历史 |
| `/api/v1/plan/*` GET | ✅ 看自己 / 自己小组 | ✅ 看全部 + 未分组数 |
| `/api/v1/plan` POST `/checkin` | ✅ 自己当前周期 | ✅ 自己当前周期(限当前周期) |
| `/api/v1/plan/file/{sid}/...` DELETE | 仅自己 sid(403) | 仅自己 sid(403) |
| `/api/v1/webview/redirect` | 凭 token | 凭 token(所有权限同 H5) |
| `/api/v1/survey/*` | ✅ | ✅(无鉴权) |
| `/api/v1/feedback` GET | ⚠ 无鉴权(已知问题) | ⚠ 无鉴权(已知问题) |
| `/api/v1/course-vote` | ✅ 自己投 | ✅ 自己投 |
| `/api/v1/yijing/*` | ✅ | ✅ |
---
## 4. 术语表
| 术语 | 含义 |
| --------------------- | --------------------------------------------------------------------------------- |
| openid | 微信用户唯一标识,由 `code_to_openid(code)` 换取 |
| token / session token | 64 位 hex,会话标识,写入 `auth_sessions` 表(不是 JWT,名字沿用老 JWT 配置) |
| class_date | 上课日 = 每周周日(周一 ~ 周日组成的自然周) |
| period_key | 计划周期键:周=YYYY-Www、月=YYYY-MM、年=YYYY |
| 小组(group) | `auth_users.group` 字段,如「01组」 |
| display_name | 形如「01组张三」(仅当 group 形如 `NN组` 时拼接) |
| webview 桥接 | 通过 `/api/v1/webview/redirect?token=...&target=...` 把 URL 上的 token 换成 cookie 再 302 |
---
## 5. 小程序前端(miniprogram/)
### 5.1 全局结构
```
miniprogram/
├── app.js App 入口:onLaunch 调 ensureLogin → /profile
├── app.json 7 个页面 + 4 tabBar + 导航主题色
├── app.wxss 全局基础样式(背景 #FDFBF7、主色 #C67B30)
├── project.config.json
├── project.private.config.json
├── sitemap.json
├── assets/ tabBar 图标(home/checkin/plan/profile × 2)
├── utils/
│ ├── api.js request / planFileUrl / 环境切换 / 401 跳登录
│ └── auth.js ensureLogin 单例 + loginWithPhone + token 存取
└── pages/
├── home/ 首页(已登录 → 内嵌 H5;游客 → 原生介绍)
├── checkin/ 打卡(老师总览+详情 / 学员小组+个人)
├── plan/ 计划总览(周/月/年)
├── plan-submit/ 计划提交(文本 + 至多 4 张图)
├── profile/ 个人主页(资料 / 头像 / 退出)
├── webview/ 通用 WebView(域名白名单)
└── privacy/ 隐私政策 + 用户协议(静态展示)
```
### 5.2 主题色与视觉规范
| token | 值 | 用途 |
| ----- | ------------------------------ | ------------- |
| 主背景 | `#FDFBF7` | 全站冷色米白底 |
| 顶栏 | `#2C1810` 渐变 → `#6B3F24` | Hero 卡背景 |
| 主交互色 | `#C67B30` | 主按钮、未选中状态、强调色 |
| 成功 | `#2E7D32`(+ 浅底 `#E8F4EB`) | 「正常」打卡、已完成 |
| 警示 | `#C0392B`(+ 浅底 `#FDEAEA`) | 「不参加讨论」、错误页 |
| 信息 | `#3A7BD5` | 「请假」 |
| 文本主 | `#2C1810` | 标题 |
| 文本辅 | `#8B7A6A` / `#999` | 描述、时间戳 |
| 圆角 | `12rpx / 16rpx / 24rpx` | 卡片、按钮、Hero |
| 字号 | 22/24/26/28/30/32/34/40/48 rpx | 时间戳到主标题 |
### 5.3 API 客户端(utils/api.js)
#### 5.3.1 环境切换(多域名)
```js
getAccountInfoSync().miniProgram.envVersion
├── 'develop' → baseUrl = https://test.muguaclub.cn/clock
├── 'trial' → baseUrl = https://test.muguaclub.cn/clock
└── 'release' → baseUrl = https://m.muguaclub.cn/clock
```
- 三个环境对应 `STATIC_BASE_URL` + `H5_BASE_URL` 同源
#### 5.3.2 request 行为约定
| 入参 | 默认 | 说明 |
| -------- | ------ | --------------------------------------------------------- |
| `url` | 必填 | 必须以 `/api/v1` 开头 |
| `method` | `GET` | 影响幂等重试 |
| `data` | `{}` | 自动 JSON |
| `auth` | `true` | true 时自动从 storage 取 `token` 加 `Authorization: Bearer xxx` |
行为:
- **401** → 删 token + `goProfile()` 切到「我的」页(带 1.2s 防连点锁)
- **幂等方法(GET / DELETE)** 失败自动 800ms 后重试一次;命中 `domain not` 错误不重试
- **非 2xx** → reject 原 data(`{error/detail/message}`)
#### 5.3.3 planFileUrl 路径约定
`attachments/{user_id}/{plan_type}/{period_key}/{filename}` →
`STATIC_BASE_URL + /clock/static/plan/<encoded/path>`
> 前 4 段缺失则返回空串,强制调用方重新加载。
### 5.4 登录态(utils/auth.js)
#### 5.4.1 单例 + 指数退避
- `loginPromise` 全局单例,解决 `App.onLaunch` 与首页 `onShow` 同时调 `ensureLogin` 的竞态
- `wxLogin` 内部失败重试 3 次(首次 600ms → 800ms → 1000ms)
- 失败兜底:`wechatLogin` 整个流程失败再重试 1 次(500ms 后)
- 任何情况下 `loginPromise` 完成后清空 → 让下一次重新发起(避免冷启动偶发失败后卡死)
#### 5.4.2 双入口登录
| 入口 | 触发 |
| ------ | ------------------------------------------------------------------------------------------------------------------- |
| 微信授权登录 | `ensureLogin()` → `/api/v1/auth/wechat-login` 用 code 换 token;后端返回 `need_phone:true` 时 resolve(null),前端引导到「我的」页绑定手机号 |
| 手机号登录 | `loginWithPhone(phoneCode)` → `/api/v1/auth/bind-phone` 必须同时传 `code` 和 `phone_code`;绑定成功后写 token 并返回 is_new:true |
### 5.5 页面清单
#### 5.5.1 home 首页
- **登录态判断**:`isLoggedIn = !!getToken()`
- **已登录** → `<web-view src="/api/v1/webview/redirect?token=...&target=...">`,key 自增强制重建(解决切 tab 白屏)
- **未登录** → 原生介绍页(Hero 头图 + 4 个模块卡 + 4 个产品介绍卡 + 引导登录按钮)
- 提供分享:`onShareAppMessage` / `onShareTimeline`
#### 5.5.2 checkin 打卡(核心)
**老师视图**
- `view = 'overview'`:所有小组打卡进度 + 自己的打卡状态
- `view = 'detail'`:点开某小组看每个成员是否打卡 / 状态
**学员视图**
- 顶部打卡卡:5 个状态按钮(正常/迟到/请假/不参加讨论/早退),单选切换
- `tab = 'group'`:本组成员列表
- `tab = 'personal'`:个人打卡历史(分页,每页 20 条)
**通用交互**
- 周选择器:`/api/v1/checkin/weeks` 返 2022 年到当前的所有上课周六
- 下拉刷新 + 上拉加载更多(个人历史 tab)
- 周计划 / 月计划 / 年计划的周期切换(plan 页面公用同一套 picker)
**打卡规则(来自 `routes/checkin.py`)**
- 状态:`正常 / 迟到 / 请假 / 不参加讨论 / 早退`
- 默认状态 `正常`
- 备注最大 500 字符(仅非「正常」时允许)
- 打卡窗口:上课日所在自然周(周一 ~ 周日)与当前周相同 / 未来周可打卡
#### 5.5.3 plan 计划(核心)
- 类型切换 tab:**周计划 / 月计划 / 年计划**
- 卡片:
- 自己的状态(已提交/待提交 + 当前周期)
- 「提交 / 修改」按钮,`canSubmit=false` 时 disabled
- 窗口提示(如「周四截止,已过提交时间」)
- 老师视图:所有小组列表
- 学员视图:tab = 小组(自己成员)/ 个人(历史最多 20 条)
- 周期 key:
- 周 = `YYYY-Www`(ISO 周)
- 月 = `YYYY-MM`
- 年 = `YYYY`
**提交时间窗(`routes/plan.py`)**
- 周:上一周五 00:00 ~ 本周四 23:59:59
- 月 / 年:仅当前周期可提交,历史 / 未来都禁止
#### 5.5.4 plan-submit 提交页
| 控件 | 限制 |
| -------- | ------------------------------------------------------------------ |
| textarea | maxlength 1000、disabled=!canSubmit |
| 图片 | 最多 4 张、允许后缀 `jpg/jpeg/png/gif/webp/bmp`、>1MB 自动逐级压缩 80→60→45 |
| 删除 | 调 `/api/v1/plan/file/{sid}/{plan_type}/{period}/{filename}` DELETE |
| 提交 | 文本或图片必有一项;已有记录弹覆盖确认 |
**上传协议**:`wx.uploadFile`,`formData = { plan_type, period_key }`,`name='files'`,**支持 multipart 多文件**
#### 5.5.5 profile 个人主页
**视图状态机**:`view ∈ { loading, profile, phoneLogin, loadError }`
- 已登录 + 有 token → 显示完整资料 + 头像 + 班级分组列表 + 退出登录
- 未登录 / need_phone → 显示手机号登录卡(强依赖隐私协议勾选)
- chooseAvatar:调 `/api/v1/profile/avatar` 单文件上传,avatar 文件名统一为 `avatar.{ext}`,旧头像自动清理
- 退出登录:调 `/api/v1/auth/logout`(尽力而为)+ 清本地 token + 清隐私同意标记 + reLaunch 回首页
#### 5.5.6 webview 通用 web-view
- 入参:`url`(必须 URL 编码)
- **域名白名单**(`isAllowedUrl`):仅允许 `m.muguaclub.cn` 和 `muguaclub.cn` 及其子域;非法 URL 渲染错误页
#### 5.5.7 privacy 隐私 / 协议页
- 两个静态文档:隐私政策 8 节、用户协议 8 节
- 通过 query `type` 切换:`privacy` / `agreement`
- 最后更新日期占位:**2026 年 9 月**
### 5.6 通用 UI 特性
- **骨架屏**:列表 / 表单 / 详情都有 `sk-*` 占位
- **错误页**:圆形「!」 + 失败文案 + 「重新加载」按钮
- **空态**:emoji + 简短说明
- **分享**:所有页面都实现 `onShareAppMessage` + `onShareTimeline`
---
## 6. 后端 API(server/)
### 6.1 工程骨架
```
server/
├── __init__.py
├── main.py FastAPI() + 挂 9 个 router + /api/v1/health
├── config.py 从 .env 读所有密钥 / 路径
├── db.py PyMySQL + DBUtils PooledDB,单例 per db
├── security.py encrypt_phone(hmac-sha256) + create_session_token
├── wechat.py code_to_openid + get_phone_number + _get_access_token
├── user.py make_display_name + get_class_groups + user_payload
├── routes/
│ ├── __init__.py
│ ├── auth.py /api/v1/auth/*
│ ├── checkin.py /api/v1/checkin/*
│ ├── plan.py /api/v1/plan/*
│ ├── profile.py /api/v1/profile*
│ ├── webview.py /api/v1/webview/redirect
│ ├── survey.py /api/v1/survey/* (无鉴权)
│ ├── feedback.py /api/v1/feedback/* (含仅 ADMIN_KEY 的管理接口)
│ ├── course_vote.py /api/v1/course-vote*
│ └── yijing.py /api/v1/yijing/* (从老 Flask 服务迁移)
└── migrations/
├── 001_add_wechat_openid_column.sql
├── 002_plan_perf_index.sql
├── 003_checkin_records_user_id.sql
└── 004_plan_checkins_user_id.sql
```
> ⚠ 已知 bug(`main.py:29`):`uvicorn.run("server_prd.main:app", ...)` 引用不存在的包,启动会 ImportError。
### 6.2 全局运行时依赖
| 依赖 | 作用 |
| ------------------ | ------------------------------------------------------------------------------------------------------------------------ |
| FastAPI | Web 框架 |
| uvicorn | ASGI 运行(默认 :8000,host 127.0.0.1,dev 模式 reload) |
| PyMySQL | MySQL 驱动 |
| DBUtils `PooledDB` | 连接池:`DB_POOL_MIN_CACHED=1 / MAX_CACHED=5 / MAX_CONNECTIONS=10 / MAX_USAGE=100`、ping=1、connect_timeout=5s、read_timeout=5s |
| httpx | 调用 `api.weixin.qq.com`(jscode2session / access_token / 手机号解密) |
| python-dotenv | `load_dotenv(dotenv_path, override=True)` |
| pydantic | 请求体 / 响应模型 |
### 6.3 配置项(config.py)
| 键 | 默认 | 用途 |
| ---------------------------------- | ------------------------------------------- | -------------------------------------------- |
| `WECHAT_APPID` / `WECHAT_SECRET` | 空 | 微信 API 凭据 |
| `JWT_SECRET` * | 空 | **代码里没有真正用上 JWT**,命名沿用历史,实际为 `auth_sessions` |
| `JWT_ALGORITHM` | `HS256` | 同上,遗留 |
| `JWT_TTL_SECONDS` | `90*24*3600`(90 天) | 会话有效期 |
| `ADMIN_KEY` | 空 | `/api/v1/feedback/resolve` 管理密钥 |
| `MYSQL_HOST/PORT/USER/PASSWORD/DB` | `localhost/3306/root/''/mys_info_v3` | 主业务库 |
| `SURVEY_DB` | `myxhxc` | 问卷库(独立库,独立连接池) |
| `PHONE_HMAC_KEY` | `MingYuan2026AuthKey` | 手机号 HMAC-SHA256 盐 |
| `H5_BASE_URL` | `http://127.0.0.1:8001` | WebView 跳转上游(H5 主站) |
| `FEEDBACK_DIR` | `/home/introduction/feedback` | 用户反馈 JSON + 图片目录 |
| `COURSE_VOTES_FILE` | `/home/introduction/test/course_votes.json` | 课程投票持久化文件 |
| `PLAN_ATTACH_DIR` | `/home/static/plan` | 计划附件根目录 |
| `STATIC_ROOT` / `AVATAR_DIR` | `/home/static[/avatars]` | 头像静态文件目录(nginx 直读) |
| `DB_POOL_*` | 上表 | 连接池参数(仅从 os.environ 读,不走 config) |
> 注:`config.py` 用 `os.environ.get(name, default)`,但 `db.py` 用 `os.environ.get("DB_POOL_*")` 直接读。
### 6.4 数据库连接池(db.py)
- 多库支持:`_get_pool(database)` 按 db 名缓存 pool,**双数据库**(主业务 + survey)
- `fetch_one / fetch_all / execute` 三个 API,借 / 还连接(`close()` 让连接回池)
- `close_all()` 用于测试 / reload hook
### 6.5 鉴权(`get_current_user`)
判定顺序:
1. `Authorization: Bearer xxx`(小程序 API 默认)
2. `Cookie: auth_token=xxx`(H5 走 webview 桥接后自动带)
3. 都无 → 401
查表:
- `auth_sessions WHERE token=? AND expires_at>NOW()` 拿到 `user_id`
- 再 `SELECT * FROM auth_users WHERE id=?`
### 6.6 接口约定
#### 6.6.1 /api/v1/auth
| 方法 | 路径 | 鉴权 | 说明 |
| ---- | --------------- | --- | ------------------------------------------------------------------------------------------------ |
| POST | `/wechat-login` | 否 | `code` → openid → 查 user;命中即发 token,未命中返 `need_phone:true` |
| POST | `/bind-phone` | 否 | `code, phone_code` → 拿手机号 → HMAC 查 user;命中即 update `wechat_openid` + 发 token;首次绑定返 `is_new:true` |
| GET | `/verify` | 是 | 返回 `user_payload` |
| POST | `/logout` | 是 | 删除 session + 清 user.wechat_openid + 删 cookie |
#### 6.6.2 /api/v1/profile
| 方法 | 路径 | 说明 |
| ---- | ----------------- | ------------------------------------------------------------------------------------------------------------- |
| GET | `/profile` | user_payload + `phone_display`(注意:**原文里 user 表无 phone_display 字段**,8.2 已知问题) + `wechat_avatar` + `login_type` |
| POST | `/profile/avatar` | multipart 单文件,4MB 上限,存 `avatar.{ext}`,旧头像自动删;URL `=/clock/static/avatars/{uid}/avatar{ext}` |
#### 6.6.3 /api/v1/checkin
| 方法 | 路径 | 说明 |
| ---- | ----------------------------------------- | ---------------------------------- |
| GET | `/weeks` | 2022 年至当前的所有上课周六列表 + current_week |
| GET | `/week` | **已弃用**,仅供旧客户端 |
| GET | `/groups?class_date=YYYY-MM-DD` | 老师→全部小组 + 未分组数;学员→自己小组 + my_group |
| GET | `/group?class_date=&group_name=` | 单组成员 + 各自打卡状态;学员传他组→403 |
| POST | `/checkin` `{status, class_date?, note?}` | upsert(按 user_id + class_date 唯一键) |
| GET | `/list?page=&size=` | 个人历史分页,默认 10,上限 50 |
#### 6.6.4 /api/v1/plan
| 方法 | 路径 | 说明 |
| ------ | ------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------- |
| GET | `/periods?type=` | 周/月/年周期列表 + current_period |
| GET | `/status?type=&period_key?=` | 看本期是否已提交 + can_submit + message |
| GET | `/groups?type=&period_key?=` | 老师看全部 + 未分组;学员看自己小组 |
| GET | `/group?type=&group_name=&period_key?=` | 单组成员列表 + 各自 filled |
| POST | `/checkin` `{plan_type, period_key?, status?, content?, attachments[]?}` | upsert(按 user_id + plan_type + period_key) |
| GET | `/history?type=&page=&size=` | 个人历史分页 |
| POST | `/upload` multipart `{plan_type, period_key, files[]}` | 至多 4 文件、每文件 ≤10MB、白名单扩展名、保存路径 `attachments/{user_id}/{plan_type}/{period_key}/{filename}` |
| GET | `/file/{sid}/{plan_type}/{period}/{filename}` | 直读静态文件 |
| DELETE | `/file/{sid}/...` | 校验 sid == user.id,否则 403 |
#### 6.6.5 /api/v1/webview
| 方法 | 路径 | 鉴权 | 说明 |
| --- | -------------------------- | -------------- | ------------------------------------------- |
| GET | `/redirect?token=&target=` | 否(凭 URL token) | 校验 token → set HttpOnly cookie → 302 target |
安全要点:
- `target` 仅允许相对路径(`/xxx`)或白名单域 `m.muguaclub.cn` 及其子域
- cookie:`HttpOnly`、`Secure(scheme=https)`、`SameSite=Lax`、`MaxAge=JWT_TTL_SECONDS`
#### 6.6.6 /api/v1/survey(独立库 `myxhxc`,无鉴权)
| 方法 | 路径 | 说明 |
| ---- | ------------------------------------------ | ------------------- |
| GET | `/tasks` | 状态为 active 的所有 task |
| GET | `/responses?task_id=` | 某 task 的所有 group 回答 |
| POST | `/response` `{task_id, group_id, answers}` | upsert |
| PUT | `/response/{rid}` `{answers}` | 更新已有 |
| POST | `/task` | 占位,功能预留 |
#### 6.6.7 /api/v1/feedback
| 方法 | 路径 | 鉴权 | 说明 |
| ---- | ------------------------------------ | --------- | --------------------------------------------- |
| GET | `""` | ❌ 无 | 读所有 json 文件,按 unsolved/solved 倒序 |
| POST | `""` `{name?, description?, image?}` | 否 | 写一个 4 位 hex id 的 JSON,可附 data:image base64 图片 |
| GET | `/file/{name}` | 否 | 直读图片 |
| POST | `/resolve?id=&token=` | ADMIN_KEY | 标记为 solved |
> ⚠ 已知问题:`/feedback` GET 当前无鉴权,任何人能看所有反馈(**安全风险**)
#### 6.6.8 /api/v1/course_vote
| 方法 | 路径 | 鉴权 | 说明 |
| ---- | ------------------------------------------------ | --- | ------------------------------ |
| POST | `/course-vote` `{votes[3-6], rating?, comment?}` | 是 | 写入 `COURSE_VOTES_FILE` JSON 文件 |
| GET | `/course-votes` | 是 | 聚合(总票数 / 评论均分)+ 我的投票 |
> 用 JSON 文件存,无数据库,便于迁移自老 Flask。
#### 6.6.9 /api/v1/yijing
| 方法 | 路径 | 鉴权 | 说明 |
| ---- | ------------------------------------------------------- | --- | ---------------------------------------------------------------- |
| GET | `/catalog` | 否 | 八卦 + 六十四卦元数据 |
| GET | `/progress` | 是 | 5 个阶段(1..5)的学习进度 |
| POST | `/progress` `{stage[1-5], item_type, item_id, status?}` | 是 | upsert |
| POST | `/quiz` `{stage, item_type, item_id, correct, answer?}` | 是 | 记一次尝试,对应进度 score++;score ≥ 2 → mastered;写入 yijing_reviews 用于复习排期 |
### 6.7 后端非业务模块
| 模块 | 用途 |
| ------------------------------ | ----------------------- |
| `start.sh` / `stop.sh` | 启停脚本(开发环境) |
| `start_prd.sh` / `stop_prd.sh` | 生产环境启停(pid 文件管理) |
| `index.html` | 部署目录下的 health 静态页 |
| `requirements.txt` | 依赖清单 |
| `.env` | **曾被 git 跟踪,含密钥**(已知问题) |
| `uvicorn.log` | 运行日志 |
---
## 7. 数据模型
> 由 `auth_users / auth_sessions / classes / groups / class_groups / group_history / checkin_records_old / plan_checkins / yijing_trigrams / yijing_hexagrams / yijing_progress / yijing_attempts / yijing_reviews / survey_tasks / survey_responses` 等表名 / SQL 反推;具体 DDL 不在 `server/` 内。
### 7.1 主业务库(mys_info_v3)
| 表 | 主键 | 关键字段 | 用途 |
| --------------------- | ---------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- |
| `auth_users` | `id` | `student_id, name, phone_encrypted(HMAC-SHA256), wechat_openid(唯一键), wechat_avatar, login_type, role, \`group\`` | 用户账号 + 资料 |
| `auth_sessions` | `id` | `user_id, token(唯一), expires_at` | 会话(90 天) |
| `classes` | `id` | `code, name` | 班级(期/班次) |
| `groups` | `id` | `group_code, sort` | 小组 |
| `class_groups` | (复合) | `class_id, group_id, user_id, role_in_group` | 班级 × 小组 × 学员 多对多 |
| `group_history` | (复合) | `user_id, class_id, old_group_id, changed_at` | 学员所在小组变迁 |
| `checkin_records_old` | (复合) `uk_checkin_user_date(user_id, class_date)` | `user_id, class_date, checked_at, status, note` | **打卡记录**(注:表名保留 `_old` 后缀,迁移按 user_id,详见 migration 003) |
| `plan_checkins` | (复合) `uk_plan_user_period(user_id, plan_type, period_key)` | `user_id, plan_type, period_key, status(已完成/未完成), checked_at, content, attachments(JSON), created_at, updated_at` + 索引 `idx_plan_period_user` | 计划记录 |
| `yijing_trigrams` | `id` | `name, symbol, nature, de, family, direction, xiantian, houtian, binary_code, class_xiang, summary` | 八卦(教材化元数据) |
| `yijing_hexagrams` | `id` | `name, full_name, symbol, upper_trigram, lower_trigram, binary_code, judgment, gua_explanation, bagong, gong_wuxing` | 六十四卦 |
| `yijing_progress` | (复合 stage, item_type, item_id) | `user_id, stage, item_type, item_id, status(learning/mastered), score, attempts, correct` | 学习进度 |
| `yijing_attempts` | `id` | `user_id, stage, item_type, item_id, correct, answer` | 尝试明细 |
| `yijing_reviews` | (复合) | `user_id, item_type, item_id, due_at` | 复习到期时间 |
### 7.2 问卷库(myxhxc)
| 表 | 关键字段 |
| ------------------ | ---------------------------------- |
| `survey_tasks` | `task_no, status(active), ...` |
| `survey_responses` | `task_id, group_id, answers(JSON)` |
### 7.3 文件存储
| 路径 | 用途 |
| ---------------------------------------------------------------------- | -------------- |
| `/home/static/avatars/{uid}/avatar{ext}` | 个人头像(nginx 直读) |
| `/home/static/plan/attachments/{user_id}/{plan_type}/{period_key}/...` | 计划附件 |
| `/home/introduction/feedback/*.json` + `images/*.png` | 反馈 JSON + 图片 |
| `/home/introduction/test/course_votes.json` | 课程投票 JSON |
### 7.4 迁移状态(migrations/)
- **001**:给 `auth_users` 加 `wechat_openid` 唯一键(绑微信前提)
- **002**:给 `plan_checkins` 加 `(plan_type, period_key, student_id)` 索引(性能,已被 004 替换)
- **003**:`checkin_records_old` 改按 `user_id` 关联,加唯一键 `(user_id, class_date)`
- **004**:`plan_checkins` 改按 `user_id` 关联,加 `(user_id, plan_type, period_key)` 唯一键 + 替换索引;附件相对路径从 student_id 改写为 user_id
---
## 8. 部署与配置
### 8.1 启动命令(开发)
```bash
pip install -r requirements.txt
# 配置 server/.env:WECHAT_APPID / WECHAT_SECRET / MYSQL_* / PHONE_HMAC_KEY / ADMIN_KEY / H5_BASE_URL
python -m uvicorn server.main:app --reload --port 8000
```
### 8.2 启动命令(生产)
```bash
# 启动
bash server/start.sh
# 停止
bash server/stop.sh
```
### 8.3 nginx 转发示例(推断)
```
# /api/v1/* → :8000 (uvicorn)
# /clock/static/* → /home/static/ (nginx 直读)
# /clock/api/v1/webview/* → :8000 (cookie 桥接)
# / → upstream muguaclub.cn / 8001 (H5 主站)
```
### 8.4 域名与端口
| 角色 | 域名 | 后端端口 |
| --- | ------------------- | -------------------------- |
| 正式 | `m.muguaclub.cn` | 8000 (FastAPI) + 8001 (H5) |
| 测试 | `test.muguaclub.cn` | 同上 |
---
## 9. 安全与合规
### 9.1 鉴权
- 单 token 体系:所有受保护接口统一用 `get_current_user`(`Authorization` 或 cookie)
- 退出登录:删 session + 解绑 openid(下次需重新授权手机号)
- WebView 桥接:URL 上短期 token → cookie,再 302 跳转同站域(防开放重定向)
### 9.2 传输安全
- 头像直链强制 https(`secure=request.url.scheme == "https"`)
- Cookie `HttpOnly + SameSite=Lax`
- OpenAPI 全 HTTPS(微信 API 仅 https)
### 9.3 数据安全
- 手机号 HMAC-SHA256 存 `phone_encrypted`,原号不出库
- 反馈、计划内容、明文入库(小程序端有 maxlength 1000 / 500 限制)
### 9.4 隐私合规
- 「我的」页强制要求勾选隐私政策 + 用户协议才能调 `getPhoneNumber`
- 隐私协议 8 节;用户协议 8 节;最后更新 2026 年 9 月(占位)
- 客服邮箱 `[此处填写客服邮箱]` 占位需补充
---
## 10. 非功能性需求
### 10.1 性能
| 维度 | 要求 |
| ------- | ----------------------------------- |
| 首屏冷启动 | 幂等请求(GET/DELETE)网络错误自动 800ms 重试 1 次 |
| 单实例 QPS | 与连接池 `maxconnections=10` 匹配;超出排队 |
| 单次连接 | connect_timeout=5s,read_timeout=5s |
| 列表分页 | 打卡默认 10,最大 50;计划默认 10,最大 50 |
| 客户端压缩 | 计划图片 >1MB 自动 80→60→45 三级降质压缩 |
| 上传上限 | 单文件 10MB(计划)/ 4MB(头像) |
### 10.2 可用性
- 单会话 90 天有效,过期即失效(前端需重新登录)
- 计划历史周期只读(can_submit=false)
- 学员只能查自己所在小组
- 切换 tab / 周选择器 / 周期选择器都有 fallback(数据为空时不刷)
### 10.3 可观测性(缺)
代码中未发现业务埋点 / 性能监控 / 错误聚合;`uvicorn.log` 仅记录关键 warn/error(如 `微信登录 code 换取 openid 失败`)。
### 10.4 兼容性
- 小程序基础库 `>=3.0.0`(`wx.getAccountInfoSync`)
- Python ≥3.9(FastAPI 最低版本)
---
## 11. 错误约定
- HTTP 状态码:
- `200` 业务正常
- `400` 参数错误(含业务校验:「仅可填写当前周期」「已过打卡时间」等)
- `401` 缺 token / token 无效或过期
- `403` 无权限(学员查他组 / 删他人附件)
- `404` 资源不存在
- `500` 未捕获的运行时错误(FastAPI 默认)
- 错误体:
- FastAPI 默认:`{"detail": "..."}`
- 业务自定义:`{"error": "..."}` 或 `{"success": false, "error": "..."}` 或 `{"need_phone": true, "error": "..."}`
- 前端解析顺序(`pages/checkin/checkin.js`):`err.detail → err.error → err.message`
---
> 文档结束。如有功能/接口改动,请同步更新本文件章节 5/6 并在文末「更新日志」追加条目。

View File

@@ -1,18 +1,134 @@
package com.mingyuan.android_client.feature.auth
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Checkbox
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.mingyuan.android_client.core.designsystem.component.MingyuanButton
import com.mingyuan.android_client.core.designsystem.theme.Danger
import com.mingyuan.android_client.core.designsystem.theme.FaintText
import com.mingyuan.android_client.core.designsystem.theme.PageBackground
import com.mingyuan.android_client.core.designsystem.theme.PrimaryText
/** Navigation route for this feature, referenced by the app-level NavHost. */
const val AUTH_ROUTE = "auth"
private val PHONE_PATTERN = Regex("^1\\d{10}$")
@Composable
fun AuthScreen(modifier: Modifier = Modifier) {
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(text = "Auth")
fun AuthScreen(
onLoggedIn: () -> Unit,
modifier: Modifier = Modifier,
viewModel: AuthViewModel = hiltViewModel(),
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
var phone by remember { mutableStateOf("") }
var code by remember { mutableStateOf("") }
var privacyAgreed by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
viewModel.loginSuccess.collect { onLoggedIn() }
}
val phoneValid = PHONE_PATTERN.matches(phone)
val canSendCode = phoneValid && uiState.countdownSeconds == 0 && !uiState.isSendingCode
val canSubmit = phoneValid && code.isNotBlank() && privacyAgreed && !uiState.isSubmitting
Column(
modifier = modifier
.fillMaxSize()
.background(PageBackground)
.verticalScroll(rememberScrollState())
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Spacer(Modifier.height(48.dp))
Text("手机号登录", color = PrimaryText, fontSize = 22.sp, fontWeight = FontWeight.Bold)
Spacer(Modifier.height(8.dp))
Text("手机号仅用于账号绑定,不会公开展示", color = FaintText, fontSize = 13.sp)
Spacer(Modifier.height(32.dp))
OutlinedTextField(
value = phone,
onValueChange = { phone = it.filter(Char::isDigit).take(11) },
label = { Text("手机号") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(16.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
OutlinedTextField(
value = code,
onValueChange = { code = it.filter(Char::isDigit).take(6) },
label = { Text("验证码") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.weight(1f),
)
Spacer(Modifier.width(12.dp))
MingyuanButton(
text = when {
uiState.countdownSeconds > 0 -> "${uiState.countdownSeconds}s 后重发"
uiState.isSendingCode -> "发送中"
else -> "获取验证码"
},
onClick = { viewModel.requestSmsCode(phone) },
enabled = canSendCode,
)
}
Spacer(Modifier.height(16.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(
checked = privacyAgreed,
onCheckedChange = { privacyAgreed = it },
)
Text(
text = "我已阅读并同意《用户协议》和《隐私政策》",
color = FaintText,
fontSize = 12.sp,
)
}
Spacer(Modifier.height(8.dp))
uiState.errorMessage?.let { message ->
Text(message, color = Danger, fontSize = 13.sp)
Spacer(Modifier.height(8.dp))
}
Spacer(Modifier.height(16.dp))
MingyuanButton(
text = if (uiState.isSubmitting) "登录中..." else "登录",
onClick = { viewModel.login(phone, code) },
enabled = canSubmit,
fullWidth = true,
)
}
}

View File

@@ -1,8 +1,74 @@
package com.mingyuan.android_client.feature.auth
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.mingyuan.android_client.core.domain.repository.LoginResult
import com.mingyuan.android_client.core.domain.repository.SessionRepository
import com.mingyuan.android_client.core.domain.repository.SmsCodeResult
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
data class AuthUiState(
val isSendingCode: Boolean = false,
val countdownSeconds: Int = 0,
val isSubmitting: Boolean = false,
val errorMessage: String? = null,
)
@HiltViewModel
class AuthViewModel @Inject constructor() : ViewModel()
class AuthViewModel @Inject constructor(
private val sessionRepository: SessionRepository,
) : ViewModel() {
private val _uiState = MutableStateFlow(AuthUiState())
val uiState: StateFlow<AuthUiState> = _uiState.asStateFlow()
private val _loginSuccess = MutableSharedFlow<Unit>()
val loginSuccess: SharedFlow<Unit> = _loginSuccess
private var countdownJob: Job? = null
fun requestSmsCode(phone: String) {
if (_uiState.value.isSendingCode || _uiState.value.countdownSeconds > 0) return
viewModelScope.launch {
_uiState.update { it.copy(isSendingCode = true, errorMessage = null) }
when (val result = sessionRepository.requestSmsCode(phone)) {
is SmsCodeResult.Success -> startCountdown()
is SmsCodeResult.Failure -> _uiState.update { it.copy(errorMessage = result.message) }
}
_uiState.update { it.copy(isSendingCode = false) }
}
}
fun login(phone: String, code: String) {
if (_uiState.value.isSubmitting) return
viewModelScope.launch {
_uiState.update { it.copy(isSubmitting = true, errorMessage = null) }
when (val result = sessionRepository.login(phone, code)) {
is LoginResult.Success -> _loginSuccess.emit(Unit)
is LoginResult.Failure -> _uiState.update { it.copy(errorMessage = result.message) }
}
_uiState.update { it.copy(isSubmitting = false) }
}
}
private fun startCountdown() {
countdownJob?.cancel()
countdownJob = viewModelScope.launch {
for (seconds in 60 downTo 1) {
_uiState.update { it.copy(countdownSeconds = seconds) }
delay(1000)
}
_uiState.update { it.copy(countdownSeconds = 0) }
}
}
}

View File

@@ -5,3 +5,7 @@ plugins {
android {
namespace = "com.mingyuan.android_client.feature.checkin"
}
dependencies {
implementation(libs.kotlinx.coroutines.core)
}

View File

@@ -1,18 +1,311 @@
package com.mingyuan.android_client.feature.checkin
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.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.mingyuan.android_client.core.designsystem.component.Avatar
import com.mingyuan.android_client.core.designsystem.component.EmptyState
import com.mingyuan.android_client.core.designsystem.component.ErrorState
import com.mingyuan.android_client.core.designsystem.component.LoggedOutState
import com.mingyuan.android_client.core.designsystem.component.SectionCard
import com.mingyuan.android_client.core.designsystem.component.SkeletonBox
import com.mingyuan.android_client.core.designsystem.theme.BrandAmber
import com.mingyuan.android_client.core.designsystem.theme.FaintText
import com.mingyuan.android_client.core.designsystem.theme.MutedText
import com.mingyuan.android_client.core.designsystem.theme.NeutralChip
import com.mingyuan.android_client.core.designsystem.theme.PageBackground
import com.mingyuan.android_client.core.designsystem.theme.PrimaryText
import com.mingyuan.android_client.core.designsystem.theme.SecondaryText
import com.mingyuan.android_client.core.designsystem.theme.StatusNormal
import com.mingyuan.android_client.core.designsystem.theme.SuccessBackground
import com.mingyuan.android_client.core.model.CheckinRecord
import com.mingyuan.android_client.core.model.CheckinStatus
import com.mingyuan.android_client.core.model.GroupMember
import com.mingyuan.android_client.core.model.PersonalCheckinRecord
import com.mingyuan.android_client.core.ui.status.StatusBadge
import com.mingyuan.android_client.core.ui.status.StatusPill
/** Navigation route for this feature, referenced by the app-level NavHost. */
const val CHECKIN_ROUTE = "checkin"
@Composable
fun CheckinScreen(modifier: Modifier = Modifier) {
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(text = "Check-in")
fun CheckinScreen(
onNavigateToAuth: () -> Unit,
modifier: Modifier = Modifier,
viewModel: CheckinViewModel = hiltViewModel(),
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val selectedStatus by viewModel.selectedStatus.collectAsStateWithLifecycle()
val submitting by viewModel.submitting.collectAsStateWithLifecycle()
LaunchedEffect(uiState) {
if (uiState is CheckinUiState.LoggedOut) {
onNavigateToAuth()
}
}
when (val state = uiState) {
is CheckinUiState.Loading -> CheckinSkeleton(modifier)
is CheckinUiState.LoggedOut -> LoggedOutState(modifier = modifier)
is CheckinUiState.Error -> ErrorState(
message = state.message,
onRetry = {},
modifier = modifier.padding(16.dp),
)
is CheckinUiState.Success -> CheckinContent(
record = state.record,
selectedStatus = selectedStatus,
submitting = submitting,
onSubmit = viewModel::submit,
modifier = modifier,
)
}
}
@Composable
private fun CheckinContent(
record: CheckinRecord,
selectedStatus: CheckinStatus,
submitting: Boolean,
onSubmit: (CheckinStatus) -> Unit,
modifier: Modifier = Modifier,
) {
var tab by remember { mutableStateOf(CheckinTab.GROUP) }
LazyColumn(
modifier = modifier.fillMaxSize().background(PageBackground),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
item { StatusTitleCard(record) }
item { StatusSelector(selectedStatus, submitting, onSubmit) }
item { RecordPanel(record, tab) { tab = it } }
}
}
@Composable
private fun StatusTitleCard(record: CheckinRecord) {
SectionCard(cornerRadius = 20) {
Row(verticalAlignment = Alignment.CenterVertically) {
Column(
modifier = Modifier
.width(62.dp)
.background(PrimaryText, RoundedCornerShape(14.dp))
.padding(vertical = 12.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(record.weekday, color = Color(0xFFE8DCCB), fontSize = 12.sp)
Text(
text = record.dayNum.toString(),
color = Color.White,
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
)
}
Spacer(Modifier.width(16.dp))
Column(Modifier.weight(1f)) {
Text(
text = if (record.checked) "已打卡" else "未打卡",
color = if (record.checked) StatusNormal else BrandAmber,
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
)
Spacer(Modifier.height(6.dp))
val status = record.status
if (record.checked && status != null) {
StatusBadge(status)
} else {
Text("请选择状态完成打卡", color = MutedText, fontSize = 12.sp)
}
}
}
}
}
@Composable
private fun StatusSelector(
selectedStatus: CheckinStatus,
submitting: Boolean,
onSubmit: (CheckinStatus) -> Unit,
) {
SectionCard {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
CheckinStatus.entries.chunked(3).forEach { row ->
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
row.forEach { status ->
StatusPill(
status = status,
selected = status == selectedStatus,
onClick = { onSubmit(status) },
modifier = Modifier.weight(1f),
)
}
repeat(3 - row.size) { Spacer(Modifier.weight(1f)) }
}
}
if (submitting) {
Text("提交中…", color = MutedText, fontSize = 12.sp)
}
}
}
}
private enum class CheckinTab { GROUP, PERSONAL }
@Composable
private fun RecordPanel(
record: CheckinRecord,
tab: CheckinTab,
onTabChange: (CheckinTab) -> Unit,
) {
SectionCard {
Row(horizontalArrangement = Arrangement.spacedBy(24.dp)) {
TabItem("小组", tab == CheckinTab.GROUP) { onTabChange(CheckinTab.GROUP) }
TabItem("个人", tab == CheckinTab.PERSONAL) { onTabChange(CheckinTab.PERSONAL) }
}
Spacer(Modifier.height(16.dp))
when (tab) {
CheckinTab.GROUP -> GroupList(record)
CheckinTab.PERSONAL -> PersonalList(record.personalHistory)
}
}
}
@Composable
private fun TabItem(label: String, selected: Boolean, onClick: () -> Unit) {
Column(Modifier.clickable(onClick = onClick)) {
Text(
text = label,
color = if (selected) BrandAmber else SecondaryText,
fontSize = 15.sp,
fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal,
)
Spacer(Modifier.height(6.dp))
Box(
Modifier
.width(40.dp)
.height(2.dp)
.background(if (selected) BrandAmber else Color.Transparent),
)
}
}
@Composable
private fun GroupList(record: CheckinRecord) {
val checkedCount = record.groupMembers.count { it.checkedIn }
Column {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Text(
text = "${record.groupName} · ${record.classDate}",
color = PrimaryText,
fontSize = 13.sp,
fontWeight = FontWeight.SemiBold,
)
Spacer(Modifier.weight(1f))
Text(
text = "$checkedCount/${record.groupMembers.size} 已打卡",
color = MutedText,
fontSize = 12.sp,
)
}
Spacer(Modifier.height(12.dp))
record.groupMembers.forEach { member ->
GroupMemberRow(member)
}
}
}
@Composable
private fun GroupMemberRow(member: GroupMember) {
Row(
Modifier.fillMaxWidth().padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Avatar(
initial = member.name,
size = 30.dp,
backgroundColor = if (member.checkedIn) SuccessBackground else NeutralChip,
textColor = if (member.checkedIn) StatusNormal else BrandAmber,
)
Spacer(Modifier.width(12.dp))
Text(member.name, color = PrimaryText, fontSize = 14.sp, modifier = Modifier.weight(1f))
val status = member.status
if (member.checkedIn && status != null) {
Text("打卡 ${member.checkinTime}", color = MutedText, fontSize = 12.sp)
Spacer(Modifier.width(8.dp))
StatusBadge(status)
} else {
Text("未打卡", color = FaintText, fontSize = 12.sp)
}
}
}
@Composable
private fun PersonalList(history: List<PersonalCheckinRecord>) {
if (history.isEmpty()) {
EmptyState("📒", "暂无个人打卡记录")
} else {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
history.forEach { item ->
Row(
Modifier.fillMaxWidth().padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = item.date,
color = PrimaryText,
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.weight(1f),
)
StatusBadge(item.status)
Spacer(Modifier.width(8.dp))
Text(item.time, color = MutedText, fontSize = 12.sp)
}
}
}
}
}
@Composable
private fun CheckinSkeleton(modifier: Modifier = Modifier) {
Column(
modifier = modifier.fillMaxSize().background(PageBackground).padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
SkeletonBox(Modifier.fillMaxWidth().height(120.dp), cornerRadius = 20)
SkeletonBox(Modifier.fillMaxWidth().height(140.dp), cornerRadius = 16)
repeat(4) {
SkeletonBox(Modifier.fillMaxWidth().height(48.dp), cornerRadius = 12)
}
}
}

View File

@@ -1,8 +1,67 @@
package com.mingyuan.android_client.feature.checkin
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.mingyuan.android_client.core.domain.repository.CheckinRepository
import com.mingyuan.android_client.core.domain.repository.SessionRepository
import com.mingyuan.android_client.core.model.CheckinRecord
import com.mingyuan.android_client.core.model.CheckinStatus
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
sealed interface CheckinUiState {
data object Loading : CheckinUiState
data class Success(val record: CheckinRecord) : CheckinUiState
data object LoggedOut : CheckinUiState
data class Error(val message: String) : CheckinUiState
}
@HiltViewModel
class CheckinViewModel @Inject constructor() : ViewModel()
class CheckinViewModel @Inject constructor(
private val checkinRepository: CheckinRepository,
private val sessionRepository: SessionRepository,
) : ViewModel() {
private val _selectedStatus = MutableStateFlow(CheckinStatus.NORMAL)
val selectedStatus: StateFlow<CheckinStatus> = _selectedStatus.asStateFlow()
private val _submitting = MutableStateFlow(false)
val submitting: StateFlow<Boolean> = _submitting.asStateFlow()
val uiState: StateFlow<CheckinUiState> = sessionRepository.isLoggedIn
.flatMapLatest { loggedIn ->
if (loggedIn) {
checkinRepository.observeCheckin()
.map<CheckinRecord, CheckinUiState>(CheckinUiState::Success)
.onStart { emit(CheckinUiState.Loading) }
} else {
flowOf(CheckinUiState.LoggedOut)
}
}
.catch { emit(CheckinUiState.Error(it.message ?: "加载失败,请稍后再试")) }
.stateIn(
scope = viewModelScope,
started = SharingStarted.Eagerly,
initialValue = CheckinUiState.Loading,
)
fun submit(status: CheckinStatus) {
_selectedStatus.value = status
viewModelScope.launch {
_submitting.value = true
checkinRepository.submitCheckin(status)
_submitting.value = false
}
}
}

View File

@@ -0,0 +1,11 @@
plugins {
alias(libs.plugins.mcl.android.feature)
}
android {
namespace = "com.mingyuan.android_client.feature.home"
}
dependencies {
implementation(libs.kotlinx.coroutines.core)
}

View File

@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest />

View File

@@ -0,0 +1,248 @@
package com.mingyuan.android_client.feature.home
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.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.mingyuan.android_client.core.designsystem.component.ErrorState
import com.mingyuan.android_client.core.designsystem.component.SectionCard
import com.mingyuan.android_client.core.designsystem.component.SkeletonBox
import com.mingyuan.android_client.core.designsystem.theme.BrandAmber
import com.mingyuan.android_client.core.designsystem.theme.HeroGradient
import com.mingyuan.android_client.core.designsystem.theme.LightAmber
import com.mingyuan.android_client.core.designsystem.theme.MutedText
import com.mingyuan.android_client.core.designsystem.theme.NeutralChip
import com.mingyuan.android_client.core.designsystem.theme.PageBackground
import com.mingyuan.android_client.core.designsystem.theme.PrimaryText
import com.mingyuan.android_client.core.designsystem.theme.SecondaryText
import com.mingyuan.android_client.core.model.HomeData
import com.mingyuan.android_client.core.model.HomeModule
import com.mingyuan.android_client.core.model.IntroCard
import com.mingyuan.android_client.core.model.ModuleDestination
/** Navigation route for this feature, referenced by the app-level NavHost. */
const val HOME_ROUTE = "home"
@Composable
fun HomeScreen(
onNavigateToCheckin: () -> Unit,
onNavigateToPlan: () -> Unit,
onNavigateToAuth: () -> Unit,
modifier: Modifier = Modifier,
viewModel: HomeViewModel = hiltViewModel(),
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
var showLoginDialog by remember { mutableStateOf(false) }
when (val state = uiState) {
is HomeUiState.Loading -> HomeSkeleton(modifier)
is HomeUiState.Error -> ErrorState(
message = state.message,
onRetry = {},
modifier = modifier.padding(16.dp),
)
is HomeUiState.Success -> HomeContent(
data = state.data,
onModuleClick = { module ->
if (state.isGuest) {
showLoginDialog = true
} else {
when (module.destination) {
ModuleDestination.CHECKIN -> onNavigateToCheckin()
ModuleDestination.PLAN -> onNavigateToPlan()
ModuleDestination.ABOUT -> Unit
}
}
},
modifier = modifier,
)
}
if (showLoginDialog) {
AlertDialog(
onDismissRequest = { showLoginDialog = false },
title = { Text("登录提示") },
text = { Text("登录后使用此功能,是否前往登录?") },
confirmButton = {
TextButton(
onClick = {
showLoginDialog = false
onNavigateToAuth()
},
) {
Text("去登录")
}
},
dismissButton = {
TextButton(onClick = { showLoginDialog = false }) { Text("取消") }
},
)
}
}
@Composable
private fun HomeSkeleton(modifier: Modifier = Modifier) {
Column(
modifier = modifier.fillMaxSize().background(PageBackground).padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
SkeletonBox(Modifier.fillMaxWidth().height(140.dp), cornerRadius = 24)
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
repeat(3) {
SkeletonBox(Modifier.weight(1f).height(104.dp), cornerRadius = 22)
}
}
}
}
@Composable
private fun HomeContent(
data: HomeData,
onModuleClick: (HomeModule) -> Unit,
modifier: Modifier = Modifier,
) {
LazyColumn(
modifier = modifier.fillMaxSize().background(PageBackground),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
item { HeroCard(data) }
item { ModuleGrid(data.modules, onModuleClick) }
item { IntroSection(data.intros) }
}
}
@Composable
private fun HeroCard(data: HomeData) {
Column(
modifier = Modifier
.fillMaxWidth()
.shadow(
elevation = 6.dp,
shape = RoundedCornerShape(24.dp),
spotColor = Color(0x2E2C1810),
ambientColor = Color(0x2E2C1810),
)
.background(HeroGradient, RoundedCornerShape(24.dp))
.padding(horizontal = 20.dp, vertical = 24.dp),
) {
Box(
modifier = Modifier
.background(Color(0x2EC67B30), RoundedCornerShape(50))
.padding(horizontal = 10.dp, vertical = 4.dp),
) {
Text(data.greeting, color = LightAmber, fontSize = 11.sp)
}
Spacer(Modifier.height(12.dp))
Text(
text = data.displayName,
color = Color.White,
fontSize = 24.sp,
fontWeight = FontWeight.Bold,
)
Spacer(Modifier.height(6.dp))
Text(data.encouragement, color = Color(0xFFE8DCCB), fontSize = 13.sp)
}
}
@Composable
private fun ModuleGrid(
modules: List<HomeModule>,
onModuleClick: (HomeModule) -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
modules.chunked(3).forEach { row ->
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
row.forEach { module ->
ModuleItem(module, onModuleClick, Modifier.weight(1f))
}
repeat(3 - row.size) {
Spacer(Modifier.weight(1f))
}
}
}
}
}
@Composable
private fun ModuleItem(
module: HomeModule,
onModuleClick: (HomeModule) -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.shadow(
elevation = 3.dp,
shape = RoundedCornerShape(22.dp),
spotColor = Color(0x0F2C1810),
ambientColor = Color(0x0F2C1810),
)
.background(Color.White, RoundedCornerShape(22.dp))
.clickable { onModuleClick(module) }
.padding(vertical = 16.dp, horizontal = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Box(
modifier = Modifier.size(44.dp).background(NeutralChip, CircleShape),
contentAlignment = Alignment.Center,
) {
Text(module.emoji, fontSize = 20.sp)
}
Spacer(Modifier.height(8.dp))
Text(module.title, color = PrimaryText, fontSize = 14.sp, fontWeight = FontWeight.Bold)
Spacer(Modifier.height(2.dp))
Text(module.description, color = MutedText, fontSize = 11.sp)
}
}
@Composable
private fun IntroSection(intros: List<IntroCard>) {
Column {
Text("产品介绍", color = PrimaryText, fontSize = 18.sp, fontWeight = FontWeight.Bold)
Spacer(Modifier.height(12.dp))
intros.forEach { intro ->
IntroItem(intro)
Spacer(Modifier.height(12.dp))
}
}
}
@Composable
private fun IntroItem(intro: IntroCard) {
SectionCard {
Text(intro.title, color = BrandAmber, fontSize = 14.sp, fontWeight = FontWeight.Bold)
Spacer(Modifier.height(6.dp))
Text(intro.body, color = SecondaryText, fontSize = 13.sp, lineHeight = 22.sp)
}
}

View File

@@ -0,0 +1,47 @@
package com.mingyuan.android_client.feature.home
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.mingyuan.android_client.core.domain.repository.HomeRepository
import com.mingyuan.android_client.core.domain.repository.SessionRepository
import com.mingyuan.android_client.core.model.HomeData
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
import javax.inject.Inject
sealed interface HomeUiState {
data object Loading : HomeUiState
data class Success(val data: HomeData, val isGuest: Boolean) : HomeUiState
data class Error(val message: String) : HomeUiState
}
@HiltViewModel
class HomeViewModel @Inject constructor(
homeRepository: HomeRepository,
sessionRepository: SessionRepository,
) : ViewModel() {
val uiState: StateFlow<HomeUiState> = sessionRepository.isLoggedIn
.flatMapLatest { loggedIn ->
val source = if (loggedIn) {
homeRepository.observeHome()
} else {
homeRepository.observeGuestHome()
}
source
.map<HomeData, HomeUiState> { HomeUiState.Success(it, isGuest = !loggedIn) }
.onStart { emit(HomeUiState.Loading) }
}
.catch { emit(HomeUiState.Error(it.message ?: "加载失败,请稍后再试")) }
.stateIn(
scope = viewModelScope,
started = SharingStarted.Eagerly,
initialValue = HomeUiState.Loading,
)
}

View File

@@ -5,3 +5,7 @@ plugins {
android {
namespace = "com.mingyuan.android_client.feature.plan"
}
dependencies {
implementation(libs.kotlinx.coroutines.core)
}

View File

@@ -1,18 +1,396 @@
package com.mingyuan.android_client.feature.plan
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.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.mingyuan.android_client.core.designsystem.component.AmberProgressBar
import com.mingyuan.android_client.core.designsystem.component.Avatar
import com.mingyuan.android_client.core.designsystem.component.EmptyState
import com.mingyuan.android_client.core.designsystem.component.ErrorState
import com.mingyuan.android_client.core.designsystem.component.LoggedOutState
import com.mingyuan.android_client.core.designsystem.component.MingyuanButton
import com.mingyuan.android_client.core.designsystem.component.SectionCard
import com.mingyuan.android_client.core.designsystem.component.SegmentedControl
import com.mingyuan.android_client.core.designsystem.component.SkeletonBox
import com.mingyuan.android_client.core.designsystem.theme.BrandAmber
import com.mingyuan.android_client.core.designsystem.theme.Danger
import com.mingyuan.android_client.core.designsystem.theme.FaintText
import com.mingyuan.android_client.core.designsystem.theme.MutedLightText
import com.mingyuan.android_client.core.designsystem.theme.MutedText
import com.mingyuan.android_client.core.designsystem.theme.NeutralChip
import com.mingyuan.android_client.core.designsystem.theme.PlanBackground
import com.mingyuan.android_client.core.designsystem.theme.PrimaryText
import com.mingyuan.android_client.core.designsystem.theme.SecondaryText
import com.mingyuan.android_client.core.designsystem.theme.StatusNormal
import com.mingyuan.android_client.core.designsystem.theme.SuccessBackground
import com.mingyuan.android_client.core.designsystem.theme.WarningBackground
import com.mingyuan.android_client.core.model.MyPlanRecord
import com.mingyuan.android_client.core.model.PlanGroup
import com.mingyuan.android_client.core.model.PlanRecord
import com.mingyuan.android_client.core.model.PlanType
/** Navigation route for this feature, referenced by the app-level NavHost. */
const val PLAN_ROUTE = "plan"
@Composable
fun PlanScreen(modifier: Modifier = Modifier) {
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(text = "Plan")
fun PlanScreen(
onNavigateToPlanSubmit: (PlanType, String) -> Unit,
onNavigateToAuth: () -> Unit,
modifier: Modifier = Modifier,
viewModel: PlanViewModel = hiltViewModel(),
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val selectedType by viewModel.selectedType.collectAsStateWithLifecycle()
LaunchedEffect(uiState) {
if (uiState is PlanUiState.LoggedOut) {
onNavigateToAuth()
}
}
if (uiState is PlanUiState.LoggedOut) {
LoggedOutState(modifier = modifier.fillMaxSize().background(PlanBackground))
return
}
Column(modifier = modifier.fillMaxSize().background(PlanBackground)) {
Box(Modifier.fillMaxWidth().padding(16.dp)) {
SegmentedControl(
labels = PlanType.entries.map { it.label },
selectedIndex = PlanType.entries.indexOf(selectedType),
onSelect = { viewModel.selectType(PlanType.entries[it]) },
)
}
when (val state = uiState) {
is PlanUiState.Loading -> PlanSkeleton()
is PlanUiState.Error -> ErrorState(
message = state.message,
onRetry = {},
modifier = Modifier.padding(16.dp),
)
is PlanUiState.Success -> PlanContent(
record = state.record,
selectedType = selectedType,
onNavigateToPlanSubmit = onNavigateToPlanSubmit,
)
is PlanUiState.LoggedOut -> Unit // handled above
}
}
}
@Composable
private fun PlanContent(
record: PlanRecord,
selectedType: PlanType,
onNavigateToPlanSubmit: (PlanType, String) -> Unit,
) {
var selectedGroup by remember { mutableStateOf<PlanGroup?>(null) }
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item { PlanHead(record, selectedType) }
item { MyPlanStatusCard(record, selectedType, onNavigateToPlanSubmit) }
val myRecord = record.myRecord
if (record.myChecked && myRecord != null) {
item { UploadedPlanCard(myRecord) }
}
val message = record.message
if (message != null) {
item { WindowMessage(message) }
}
if (record.groups.isEmpty()) {
item { EmptyState("📭", "暂无小组") }
} else {
item {
Text("小组打卡", color = PrimaryText, fontSize = 15.sp, fontWeight = FontWeight.Bold)
}
items(record.groups, key = { it.id }) { group ->
GroupCard(group) { selectedGroup = group }
}
}
if (record.unassignedCount > 0) {
item {
Text(
text = "另有未分组 ${record.unassignedCount} 人",
color = FaintText,
fontSize = 12.sp,
)
}
}
}
selectedGroup?.let { group ->
GroupDetailDialog(group, onDismiss = { selectedGroup = null })
}
}
@Composable
private fun PlanHead(record: PlanRecord, selectedType: PlanType) {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text(selectedType.label, color = PrimaryText, fontSize = 20.sp, fontWeight = FontWeight.Bold)
Text(record.periodKey, color = MutedLightText, fontSize = 12.sp)
}
Box(
Modifier
.background(
if (record.myChecked) SuccessBackground else WarningBackground,
RoundedCornerShape(50),
)
.padding(horizontal = 12.dp, vertical = 6.dp),
) {
Text(
text = if (record.myChecked) "已提交" else "待提交",
color = if (record.myChecked) StatusNormal else BrandAmber,
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold,
)
}
}
}
@Composable
private fun MyPlanStatusCard(
record: PlanRecord,
selectedType: PlanType,
onNavigateToPlanSubmit: (PlanType, String) -> Unit,
) {
SectionCard(cornerRadius = 16) {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier
.size(44.dp)
.background(
if (record.myChecked) {
Brush.linearGradient(listOf(StatusNormal, Color(0xFF43A047)))
} else {
Brush.linearGradient(listOf(Danger, Color(0xFFE5736C)))
},
CircleShape,
),
contentAlignment = Alignment.Center,
) {
Text(
text = if (record.myChecked) "✓" else "●",
color = Color.White,
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
)
}
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
Text(
text = if (record.myChecked) "已完成" else "未完成",
color = if (record.myChecked) StatusNormal else Danger,
fontSize = 15.sp,
fontWeight = FontWeight.Bold,
)
Text("我的${selectedType.label}", color = MutedText, fontSize = 12.sp)
}
MingyuanButton(
text = if (record.myChecked) "修改" else "新增打卡",
onClick = { onNavigateToPlanSubmit(selectedType, record.periodKey) },
enabled = record.canSubmit,
)
}
}
}
@Composable
private fun UploadedPlanCard(myRecord: MyPlanRecord) {
SectionCard {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = "我上传的计划",
color = PrimaryText,
fontSize = 15.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier.weight(1f),
)
Box(
Modifier
.background(WarningBackground, RoundedCornerShape(50))
.padding(horizontal = 8.dp, vertical = 3.dp),
) {
Text("未完成", color = BrandAmber, fontSize = 11.sp, fontWeight = FontWeight.SemiBold)
}
}
Spacer(Modifier.height(8.dp))
Text("${myRecord.submittedTime} 提交", color = MutedText, fontSize = 12.sp)
Spacer(Modifier.height(8.dp))
Text(myRecord.content, color = SecondaryText, fontSize = 13.sp, lineHeight = 20.sp)
if (myRecord.attachments.isNotEmpty()) {
Spacer(Modifier.height(8.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
repeat(myRecord.attachments.size) {
ThumbnailPlaceholder()
}
}
}
}
}
@Composable
private fun ThumbnailPlaceholder() {
Box(
Modifier.size(64.dp).background(NeutralChip, RoundedCornerShape(8.dp)),
contentAlignment = Alignment.Center,
) {
Text("🖼️", fontSize = 20.sp)
}
}
@Composable
private fun WindowMessage(message: String) {
Row(
Modifier
.fillMaxWidth()
.background(WarningBackground, RoundedCornerShape(10.dp))
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text("⏰", fontSize = 14.sp)
Spacer(Modifier.width(8.dp))
Text(message, color = Danger, fontSize = 12.sp)
}
}
@Composable
private fun GroupCard(group: PlanGroup, onClick: () -> Unit) {
val progress = if (group.memberCount > 0) {
group.checkedCount.toFloat() / group.memberCount
} else {
0f
}
SectionCard(cornerRadius = 14) {
Row(
modifier = Modifier.clickable(onClick = onClick),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
Modifier
.size(40.dp)
.background(
Brush.linearGradient(listOf(Color(0xFFF3E3CF), Color(0xFFFBE7D1))),
RoundedCornerShape(12.dp),
),
contentAlignment = Alignment.Center,
) {
Text(group.name.take(1), color = BrandAmber, fontWeight = FontWeight.Bold)
}
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
Text(group.name, color = PrimaryText, fontSize = 14.sp, fontWeight = FontWeight.SemiBold)
Spacer(Modifier.height(6.dp))
AmberProgressBar(progress)
}
Spacer(Modifier.width(12.dp))
Text(
text = "已打卡 ${group.checkedCount}",
color = StatusNormal,
fontSize = 11.sp,
fontWeight = FontWeight.SemiBold,
)
Spacer(Modifier.width(6.dp))
Text(
text = "${group.memberCount}人",
color = BrandAmber,
fontSize = 11.sp,
fontWeight = FontWeight.SemiBold,
)
Spacer(Modifier.width(4.dp))
Text("›", color = FaintText, fontSize = 18.sp)
}
}
}
@Composable
private fun GroupDetailDialog(group: PlanGroup, onDismiss: () -> Unit) {
AlertDialog(
onDismissRequest = onDismiss,
confirmButton = {
TextButton(onClick = onDismiss) { Text("关闭") }
},
title = { Text(group.name, fontSize = 16.sp, fontWeight = FontWeight.Bold) },
text = {
Column {
group.members.forEach { member ->
Row(
Modifier.fillMaxWidth().padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Avatar(
initial = member.name,
size = 32.dp,
backgroundColor = if (member.filled) SuccessBackground else NeutralChip,
textColor = if (member.filled) StatusNormal else BrandAmber,
)
Spacer(Modifier.width(12.dp))
Text(
text = member.name,
color = PrimaryText,
fontSize = 14.sp,
modifier = Modifier.weight(1f),
)
Text(
text = if (member.filled) "已完成" else "未完成",
color = if (member.filled) StatusNormal else Danger,
fontSize = 12.sp,
)
}
}
}
},
)
}
@Composable
private fun PlanSkeleton() {
Column(
Modifier.fillMaxSize().padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
SkeletonBox(Modifier.fillMaxWidth().height(40.dp), cornerRadius = 12)
SkeletonBox(Modifier.fillMaxWidth().height(80.dp), cornerRadius = 16)
repeat(3) { SkeletonBox(Modifier.fillMaxWidth().height(72.dp), cornerRadius = 14) }
}
}

View File

@@ -0,0 +1,257 @@
package com.mingyuan.android_client.feature.plan
import android.net.Uri
import androidx.compose.foundation.background
import androidx.compose.foundation.border
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.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.graphics.SolidColor
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.mingyuan.android_client.core.designsystem.component.EmptyState
import com.mingyuan.android_client.core.designsystem.component.ErrorState
import com.mingyuan.android_client.core.designsystem.component.MingyuanButton
import com.mingyuan.android_client.core.designsystem.component.SectionCard
import com.mingyuan.android_client.core.designsystem.component.SkeletonBox
import com.mingyuan.android_client.core.designsystem.theme.BrandAmber
import com.mingyuan.android_client.core.designsystem.theme.Disabled
import com.mingyuan.android_client.core.designsystem.theme.FaintText
import com.mingyuan.android_client.core.designsystem.theme.MutedText
import com.mingyuan.android_client.core.designsystem.theme.PageBackground
import com.mingyuan.android_client.core.designsystem.theme.PrimaryText
import com.mingyuan.android_client.core.designsystem.theme.SecondaryText
import com.mingyuan.android_client.core.designsystem.theme.TextareaBorder
import com.mingyuan.android_client.core.model.MyPlanRecord
import com.mingyuan.android_client.core.model.PlanSubmitData
import com.mingyuan.android_client.core.model.PlanType
/** Navigation route for this feature, referenced by the app-level NavHost. */
const val PLAN_SUBMIT_ROUTE = "plan_submit?planType={planType}&periodKey={periodKey}"
fun planSubmitRoute(planType: PlanType, periodKey: String): String =
"plan_submit?planType=${planType.name}&periodKey=${Uri.encode(periodKey)}"
@Composable
fun PlanSubmitScreen(
onBack: () -> Unit,
modifier: Modifier = Modifier,
viewModel: PlanSubmitViewModel = hiltViewModel(),
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val saving by viewModel.saving.collectAsStateWithLifecycle()
val saved by viewModel.saved.collectAsStateWithLifecycle()
var content by remember { mutableStateOf("") }
LaunchedEffect(uiState) {
val state = uiState
if (state is PlanSubmitUiState.Success && content.isEmpty() && state.data.content.isNotEmpty()) {
content = state.data.content
}
}
LaunchedEffect(saved) {
if (saved) {
onBack()
}
}
Column(modifier = modifier.fillMaxSize().background(PageBackground)) {
SubmitHeader(viewModel.planType, viewModel.periodKey, onBack)
when (val state = uiState) {
is PlanSubmitUiState.Loading -> SubmitSkeleton()
is PlanSubmitUiState.Error -> ErrorState(
message = state.message,
onRetry = {},
modifier = Modifier.padding(16.dp),
)
is PlanSubmitUiState.Success -> SubmitContent(
data = state.data,
content = content,
onContentChange = { content = it },
saving = saving,
onSave = { viewModel.save(content) },
)
}
}
}
@Composable
private fun SubmitHeader(planType: PlanType, periodKey: String, onBack: () -> Unit) {
Row(
Modifier.fillMaxWidth().background(Color.White).padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "‹",
color = PrimaryText,
fontSize = 24.sp,
modifier = Modifier.clickable(onClick = onBack).padding(end = 8.dp),
)
Spacer(Modifier.width(8.dp))
Text(
text = "${planType.label} · $periodKey",
color = PrimaryText,
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
)
}
}
@Composable
private fun SubmitContent(
data: PlanSubmitData,
content: String,
onContentChange: (String) -> Unit,
saving: Boolean,
onSave: () -> Unit,
) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
val message = data.message
if (!data.canSubmit && message != null) {
item { Text(message, color = MutedText, fontSize = 12.sp) }
}
item {
PlanTextarea(
content = content,
onContentChange = onContentChange,
enabled = data.canSubmit,
)
}
item { UploadButton(attachmentsCount = data.attachments.size, enabled = data.canSubmit) }
item {
MingyuanButton(
text = when {
!data.canSubmit -> "不可提交"
data.checked -> "保存修改"
else -> "保存计划"
},
onClick = onSave,
enabled = data.canSubmit && content.isNotBlank() && !saving,
fullWidth = true,
)
}
item { HistorySection(data.history) }
}
}
@Composable
private fun PlanTextarea(
content: String,
onContentChange: (String) -> Unit,
enabled: Boolean,
) {
Column {
Box(
Modifier
.fillMaxWidth()
.heightIn(min = 120.dp)
.border(1.dp, TextareaBorder, RoundedCornerShape(12.dp))
.background(if (enabled) PageBackground else Color(0xFFF6F1EA), RoundedCornerShape(12.dp))
.padding(12.dp),
) {
if (content.isEmpty()) {
Text("写下计划内容", color = FaintText, fontSize = 14.sp)
}
BasicTextField(
value = content,
onValueChange = { if (it.length <= 1000) onContentChange(it) },
enabled = enabled,
textStyle = TextStyle(color = PrimaryText, fontSize = 14.sp),
cursorBrush = SolidColor(BrandAmber),
modifier = Modifier.fillMaxWidth(),
)
}
Spacer(Modifier.height(4.dp))
Text(
text = "${content.length}/1000",
color = FaintText,
fontSize = 12.sp,
modifier = Modifier.align(Alignment.End),
)
}
}
@Composable
private fun UploadButton(attachmentsCount: Int, enabled: Boolean) {
Box(
Modifier
.fillMaxWidth()
.border(2.dp, if (enabled) BrandAmber else Disabled, RoundedCornerShape(12.dp))
.background(PageBackground, RoundedCornerShape(12.dp))
.padding(12.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = "+ 添加图片($attachmentsCount/4)",
color = if (enabled) BrandAmber else Disabled,
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
)
}
}
@Composable
private fun HistorySection(history: List<MyPlanRecord>) {
Column {
Text("历史计划", color = PrimaryText, fontSize = 16.sp, fontWeight = FontWeight.Bold)
Spacer(Modifier.height(8.dp))
if (history.isEmpty()) {
EmptyState("📭", "暂无历史计划")
} else {
history.forEach { item ->
SectionCard {
Text("${item.submittedTime} · ${item.status}", color = BrandAmber, fontSize = 12.sp)
Spacer(Modifier.height(4.dp))
Text(item.content, color = SecondaryText, fontSize = 13.sp)
}
}
}
}
}
@Composable
private fun SubmitSkeleton() {
Column(
Modifier.fillMaxSize().padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
SkeletonBox(Modifier.fillMaxWidth().height(120.dp), cornerRadius = 12)
SkeletonBox(Modifier.fillMaxWidth().height(48.dp), cornerRadius = 12)
SkeletonBox(Modifier.fillMaxWidth().height(48.dp), cornerRadius = 50)
}
}

View File

@@ -0,0 +1,63 @@
package com.mingyuan.android_client.feature.plan
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.mingyuan.android_client.core.domain.repository.PlanRepository
import com.mingyuan.android_client.core.model.PlanSubmitData
import com.mingyuan.android_client.core.model.PlanType
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
sealed interface PlanSubmitUiState {
data object Loading : PlanSubmitUiState
data class Success(val data: PlanSubmitData) : PlanSubmitUiState
data class Error(val message: String) : PlanSubmitUiState
}
@HiltViewModel
class PlanSubmitViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val planRepository: PlanRepository,
) : ViewModel() {
val planType: PlanType = savedStateHandle.get<String>("planType")
?.let { runCatching { PlanType.valueOf(it) }.getOrNull() }
?: PlanType.WEEKLY
val periodKey: String = savedStateHandle.get<String>("periodKey").orEmpty()
private val _saving = MutableStateFlow(false)
val saving: StateFlow<Boolean> = _saving.asStateFlow()
private val _saved = MutableStateFlow(false)
val saved: StateFlow<Boolean> = _saved.asStateFlow()
val uiState: StateFlow<PlanSubmitUiState> = planRepository.observePlanSubmit(planType, periodKey)
.map<PlanSubmitData, PlanSubmitUiState>(PlanSubmitUiState::Success)
.onStart { emit(PlanSubmitUiState.Loading) }
.catch { emit(PlanSubmitUiState.Error(it.message ?: "加载失败,请稍后再试")) }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = PlanSubmitUiState.Loading,
)
fun save(content: String) {
viewModelScope.launch {
_saving.value = true
planRepository.savePlan(planType, periodKey, content, emptyList())
_saving.value = false
_saved.value = true
}
}
}

View File

@@ -1,8 +1,60 @@
package com.mingyuan.android_client.feature.plan
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.mingyuan.android_client.core.domain.repository.PlanRepository
import com.mingyuan.android_client.core.domain.repository.SessionRepository
import com.mingyuan.android_client.core.model.PlanRecord
import com.mingyuan.android_client.core.model.PlanType
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
import javax.inject.Inject
sealed interface PlanUiState {
data object Loading : PlanUiState
data class Success(val record: PlanRecord) : PlanUiState
data object LoggedOut : PlanUiState
data class Error(val message: String) : PlanUiState
}
@HiltViewModel
class PlanViewModel @Inject constructor() : ViewModel()
class PlanViewModel @Inject constructor(
private val planRepository: PlanRepository,
private val sessionRepository: SessionRepository,
) : ViewModel() {
private val _selectedType = MutableStateFlow(PlanType.WEEKLY)
val selectedType: StateFlow<PlanType> = _selectedType.asStateFlow()
val uiState: StateFlow<PlanUiState> =
combine(sessionRepository.isLoggedIn, _selectedType) { loggedIn, type -> loggedIn to type }
.flatMapLatest { (loggedIn, type) ->
if (loggedIn) {
planRepository.observePlan(type)
.map<PlanRecord, PlanUiState>(PlanUiState::Success)
.onStart { emit(PlanUiState.Loading) }
} else {
flowOf(PlanUiState.LoggedOut)
}
}
.catch { emit(PlanUiState.Error(it.message ?: "加载失败,请稍后再试")) }
.stateIn(
scope = viewModelScope,
started = SharingStarted.Eagerly,
initialValue = PlanUiState.Loading,
)
fun selectType(type: PlanType) {
_selectedType.value = type
}
}

View File

@@ -5,3 +5,7 @@ plugins {
android {
namespace = "com.mingyuan.android_client.feature.profile"
}
dependencies {
implementation(libs.kotlinx.coroutines.core)
}

View File

@@ -1,18 +1,225 @@
package com.mingyuan.android_client.feature.profile
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.mingyuan.android_client.core.designsystem.component.Avatar
import com.mingyuan.android_client.core.designsystem.component.ButtonVariant
import com.mingyuan.android_client.core.designsystem.component.ErrorState
import com.mingyuan.android_client.core.designsystem.component.LabeledRow
import com.mingyuan.android_client.core.designsystem.component.LoggedOutState
import com.mingyuan.android_client.core.designsystem.component.MingyuanButton
import com.mingyuan.android_client.core.designsystem.component.SectionCard
import com.mingyuan.android_client.core.designsystem.component.SkeletonBox
import com.mingyuan.android_client.core.designsystem.theme.BrandAmber
import com.mingyuan.android_client.core.designsystem.theme.Danger
import com.mingyuan.android_client.core.designsystem.theme.MutedText
import com.mingyuan.android_client.core.designsystem.theme.NeutralChip
import com.mingyuan.android_client.core.designsystem.theme.PageBackground
import com.mingyuan.android_client.core.designsystem.theme.PrimaryText
import com.mingyuan.android_client.core.model.ClassGroup
import com.mingyuan.android_client.core.model.UserProfile
/** Navigation route for this feature, referenced by the app-level NavHost. */
const val PROFILE_ROUTE = "profile"
@Composable
fun ProfileScreen(modifier: Modifier = Modifier) {
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(text = "Profile")
fun ProfileScreen(
onNavigateToAuth: () -> Unit,
onLoggedOut: () -> Unit,
modifier: Modifier = Modifier,
viewModel: ProfileViewModel = hiltViewModel(),
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
var showLogoutDialog by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
viewModel.logoutEvents.collect { onLoggedOut() }
}
when (val state = uiState) {
is ProfileUiState.Loading -> ProfileSkeleton(modifier)
is ProfileUiState.LoggedOut -> LoggedOutState(onLogin = onNavigateToAuth, modifier = modifier)
is ProfileUiState.Error -> ErrorState(
message = state.message,
onRetry = {},
modifier = modifier.padding(16.dp),
)
is ProfileUiState.Success -> ProfileContent(
profile = state.profile,
onLogout = { showLogoutDialog = true },
modifier = modifier,
)
}
if (showLogoutDialog) {
AlertDialog(
onDismissRequest = { showLogoutDialog = false },
title = { Text("退出登录") },
text = { Text("确定要退出登录吗?退出后需要重新登录。") },
confirmButton = {
TextButton(
onClick = {
showLogoutDialog = false
viewModel.logout()
},
) {
Text("确定", color = Danger)
}
},
dismissButton = {
TextButton(onClick = { showLogoutDialog = false }) { Text("取消") }
},
)
}
}
@Composable
private fun ProfileContent(
profile: UserProfile,
onLogout: () -> Unit,
modifier: Modifier = Modifier,
) {
LazyColumn(
modifier = modifier.fillMaxSize().background(PageBackground),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
item { ProfileHeaderCard(profile) }
item { InfoCard(profile) }
if (profile.classGroups.isNotEmpty()) {
item { ClassGroupSection(profile.classGroups) }
}
item {
MingyuanButton(
text = "退出登录",
onClick = onLogout,
variant = ButtonVariant.DANGER,
fullWidth = true,
)
}
}
}
@Composable
private fun ProfileHeaderCard(profile: UserProfile) {
SectionCard {
Row(verticalAlignment = Alignment.CenterVertically) {
Box {
Avatar(initial = profile.displayName, size = 56.dp)
Box(
Modifier
.align(Alignment.BottomEnd)
.size(20.dp)
.background(BrandAmber, CircleShape)
.border(2.dp, PageBackground, CircleShape),
contentAlignment = Alignment.Center,
) {
Text("📷", fontSize = 10.sp)
}
}
Spacer(Modifier.width(16.dp))
Column(Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = profile.displayName,
color = PrimaryText,
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
)
Spacer(Modifier.width(8.dp))
Box(
Modifier
.background(NeutralChip, RoundedCornerShape(50))
.padding(horizontal = 8.dp, vertical = 3.dp),
) {
Text(
text = profile.role,
color = BrandAmber,
fontSize = 11.sp,
fontWeight = FontWeight.SemiBold,
)
}
}
Spacer(Modifier.height(4.dp))
Text(profile.studentId, color = MutedText, fontSize = 12.sp)
}
}
}
}
@Composable
private fun InfoCard(profile: UserProfile) {
SectionCard {
Text("个人信息", color = PrimaryText, fontSize = 15.sp, fontWeight = FontWeight.Bold)
Spacer(Modifier.height(4.dp))
LabeledRow("姓名", profile.name)
LabeledRow("学号", profile.studentId)
LabeledRow("手机号", profile.phone ?: "未绑定")
LabeledRow("角色", profile.role)
LabeledRow("小组", profile.group ?: "未分组")
LabeledRow("登录方式", profile.loginType)
}
}
@Composable
private fun ClassGroupSection(classGroups: List<ClassGroup>) {
SectionCard {
Text("小组分组", color = PrimaryText, fontSize = 15.sp, fontWeight = FontWeight.Bold)
Spacer(Modifier.height(4.dp))
classGroups.forEach { group ->
LabeledRow(group.period, "${group.group} · ${group.role}")
}
}
}
@Composable
private fun ProfileSkeleton(modifier: Modifier = Modifier) {
Column(
modifier = modifier.fillMaxSize().background(PageBackground).padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
SkeletonBox(Modifier.size(56.dp), cornerRadius = 28)
Spacer(Modifier.width(16.dp))
Column {
SkeletonBox(Modifier.width(120.dp).height(20.dp))
Spacer(Modifier.height(8.dp))
SkeletonBox(Modifier.width(80.dp).height(12.dp))
}
}
SkeletonBox(Modifier.fillMaxWidth().height(200.dp), cornerRadius = 16)
SkeletonBox(Modifier.fillMaxWidth().height(48.dp), cornerRadius = 50)
}
}

View File

@@ -1,8 +1,61 @@
package com.mingyuan.android_client.feature.profile
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.mingyuan.android_client.core.domain.repository.ProfileRepository
import com.mingyuan.android_client.core.domain.repository.SessionRepository
import com.mingyuan.android_client.core.model.UserProfile
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
sealed interface ProfileUiState {
data object Loading : ProfileUiState
data class Success(val profile: UserProfile) : ProfileUiState
data object LoggedOut : ProfileUiState
data class Error(val message: String) : ProfileUiState
}
@HiltViewModel
class ProfileViewModel @Inject constructor() : ViewModel()
class ProfileViewModel @Inject constructor(
private val profileRepository: ProfileRepository,
private val sessionRepository: SessionRepository,
) : ViewModel() {
val uiState: StateFlow<ProfileUiState> = sessionRepository.isLoggedIn
.flatMapLatest { loggedIn ->
if (loggedIn) {
profileRepository.observeProfile()
.map<UserProfile, ProfileUiState>(ProfileUiState::Success)
.onStart { emit(ProfileUiState.Loading) }
} else {
flowOf(ProfileUiState.LoggedOut)
}
}
.catch { emit(ProfileUiState.Error(it.message ?: "加载失败,请稍后再试")) }
.stateIn(
scope = viewModelScope,
started = SharingStarted.Eagerly,
initialValue = ProfileUiState.Loading,
)
private val _logoutEvents = MutableSharedFlow<Unit>()
val logoutEvents: SharedFlow<Unit> = _logoutEvents
fun logout() {
viewModelScope.launch {
sessionRepository.logout()
_logoutEvents.emit(Unit)
}
}
}

View File

@@ -33,6 +33,7 @@ androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "u
androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-compose-material-icons-core = { group = "androidx.compose.material", name = "material-icons-core" }
# AndroidX
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "androidxCore" }

View File

@@ -0,0 +1,19 @@
# 服务器凭据模板 —— 复制为 secrets.properties 并填入真实值
# secrets.properties 已被 .gitignore 忽略(绝不提交);本 example 文件可安全提交,供 clone 后照着填。
#
# ---- SSH(生产机)----
SSH_HOST=
SSH_PORT=22
SSH_USER=
SSH_PASSWORD=
# ---- MySQL 生产库 ----
MYSQL_PROD_HOST=
MYSQL_PROD_PORT=3306
MYSQL_PROD_USER=
MYSQL_PROD_PASSWORD=
MYSQL_PROD_DB=mys_info_v3
# ---- 后端 API ----
API_BASE_URL=https://m.muguaclub.cn/clock
API_TOKEN=

View File

@@ -36,6 +36,7 @@ include(":core:database")
include(":core:datastore")
include(":core:data")
include(":feature:auth")
include(":feature:home")
include(":feature:checkin")
include(":feature:plan")
include(":feature:course")