feat(m0): 后端骨架 + Web/安卓前端骨架 + 设计文档
- backend: FastAPI 应用工厂、统一错误信封、JWT、短信登录、工作区/成员/邀请、幂等中间件 - alembic 0001: workspace/member/invitation/audit_log/app_user + RLS 策略 + 双角色 GRANT - tests: 认证/工作区/租户隔离/幂等 四组用例(尚未在本机执行) - web: React18+Vite 骨架(登录、工作区、邀请落地页) - android: Compose 骨架(登录、工作区列表) - 2.Design: PRODUCT-PLAN / ROADMAP-M0-M1 / NAMING / DEVELOPMENT
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
package com.dadao.task
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
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.Modifier
|
||||
import com.dadao.task.data.Api
|
||||
import com.dadao.task.data.TokenStore
|
||||
import com.dadao.task.ui.DadaoTheme
|
||||
import com.dadao.task.ui.HomeScreen
|
||||
import com.dadao.task.ui.LoginScreen
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContent {
|
||||
DadaoTheme {
|
||||
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
|
||||
AppRoot()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum class Screen { Splash, Login, Home }
|
||||
|
||||
@Composable
|
||||
private fun AppRoot() {
|
||||
val context = androidx.compose.ui.platform.LocalContext.current
|
||||
val tokens = remember { TokenStore(context) }
|
||||
val api = remember { Api(tokens) }
|
||||
var screen by remember { mutableStateOf(if (tokens.isLoggedIn) Screen.Splash else Screen.Login) }
|
||||
|
||||
// 有本地令牌就先静默校验一次:令牌过期/被吊销要退回登录页,而不是进了主页再崩。
|
||||
LaunchedEffect(screen) {
|
||||
if (screen == Screen.Splash) {
|
||||
screen = try {
|
||||
api.me()
|
||||
Screen.Home
|
||||
} catch (e: Exception) {
|
||||
tokens.clear()
|
||||
Screen.Login
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
when (screen) {
|
||||
Screen.Splash -> Unit // 校验中,空白过渡即可
|
||||
Screen.Login -> LoginScreen(api = api, onLoggedIn = { screen = Screen.Home })
|
||||
Screen.Home -> HomeScreen(
|
||||
api = api,
|
||||
onLogout = {
|
||||
tokens.clear()
|
||||
screen = Screen.Login
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package com.dadao.task.data
|
||||
|
||||
import com.dadao.task.BuildConfig
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/** 与后端信封一致的错误类型。 */
|
||||
class ApiException(val code: Int, override val message: String, val requestId: String? = null) :
|
||||
Exception(message)
|
||||
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
explicitNulls = false
|
||||
encodeDefaults = true
|
||||
}
|
||||
|
||||
@Serializable
|
||||
private data class Envelope<T>(
|
||||
val code: Int,
|
||||
val message: String,
|
||||
val data: T? = null,
|
||||
@SerialName("request_id") val requestId: String? = null,
|
||||
)
|
||||
|
||||
// ---- 与 backend/app/api/schemas.py 一一对应 ----
|
||||
|
||||
@Serializable
|
||||
data class User(val id: String, val phone: String, @SerialName("display_name") val displayName: String)
|
||||
|
||||
@Serializable
|
||||
data class Tokens(
|
||||
@SerialName("access_token") val accessToken: String,
|
||||
@SerialName("refresh_token") val refreshToken: String,
|
||||
@SerialName("expires_in") val expiresIn: Int,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class LoginResult(val user: User, val tokens: Tokens)
|
||||
|
||||
@Serializable
|
||||
data class Workspace(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val slug: String,
|
||||
val role: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MeResult(val user: User, val workspaces: List<Workspace> = emptyList())
|
||||
|
||||
@Serializable
|
||||
data class Member(
|
||||
val userId: String,
|
||||
@SerialName("display_name") val displayName: String,
|
||||
val role: String,
|
||||
val status: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Invitation(
|
||||
val code: String,
|
||||
@SerialName("expires_at") val expiresAt: String,
|
||||
)
|
||||
|
||||
private data class SmsRequest(val phone: String)
|
||||
private data class LoginRequest(val phone: String, val code: String)
|
||||
private data class RefreshRequest(@SerialName("refresh_token") val refreshToken: String)
|
||||
private data class WorkspaceCreateRequest(val name: String)
|
||||
|
||||
/**
|
||||
* 后端 API 客户端。
|
||||
*
|
||||
* 约定:所有方法返回业务 data,业务失败抛 [ApiException](code != 0),
|
||||
* 与 Web 端 lib/client.ts 的行为保持一致。
|
||||
*/
|
||||
class Api(private val tokens: TokenStore) {
|
||||
|
||||
private val http = OkHttpClient.Builder()
|
||||
.connectTimeout(10, TimeUnit.SECONDS)
|
||||
.readTimeout(20, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
private val jsonMedia = "application/json; charset=utf-8".toMediaType()
|
||||
|
||||
// 与 backend/app/config.py 的 api_prefix 保持一致
|
||||
private val base = BuildConfig.API_BASE.trimEnd('/') + "/api/v1"
|
||||
|
||||
suspend fun sendSms(phone: String): JsonElement? = call(
|
||||
path = "/auth/sms/send",
|
||||
body = json.encodeToString(SmsRequest(phone)),
|
||||
)
|
||||
|
||||
suspend fun login(phone: String, code: String): LoginResult {
|
||||
val result: LoginResult = call(
|
||||
path = "/auth/login",
|
||||
body = json.encodeToString(LoginRequest(phone, code)),
|
||||
)
|
||||
tokens.save(result.tokens.accessToken, result.tokens.refreshToken)
|
||||
return result
|
||||
}
|
||||
|
||||
suspend fun me(): MeResult = call(path = "/auth/me", auth = true)
|
||||
|
||||
suspend fun workspaces(): List<Workspace> = call(path = "/workspaces", auth = true)
|
||||
|
||||
suspend fun createWorkspace(name: String): Workspace = call(
|
||||
path = "/workspaces",
|
||||
body = json.encodeToString(WorkspaceCreateRequest(name)),
|
||||
auth = true,
|
||||
)
|
||||
|
||||
suspend fun members(workspaceId: String): List<Member> =
|
||||
call(path = "/workspaces/$workspaceId/members", auth = true)
|
||||
|
||||
suspend fun createInvitation(workspaceId: String): Invitation =
|
||||
call(path = "/workspaces/$workspaceId/invitations", body = "{}", auth = true)
|
||||
|
||||
suspend fun join(workspaceId: String, code: String): Workspace = call(
|
||||
path = "/workspaces/$workspaceId/join",
|
||||
body = json.encodeToString(mapOf("code" to code)),
|
||||
auth = true,
|
||||
)
|
||||
|
||||
private suspend inline fun <reified T> call(
|
||||
path: String,
|
||||
body: String? = null,
|
||||
auth: Boolean = false,
|
||||
method: String? = null,
|
||||
): T = withContext(Dispatchers.IO) {
|
||||
val builder = Request.Builder().url(base + path)
|
||||
val verb = method ?: if (body == null) "GET" else "POST"
|
||||
if (body != null) {
|
||||
builder.method(verb, body.toRequestBody(jsonMedia))
|
||||
} else {
|
||||
builder.method(verb, null)
|
||||
}
|
||||
builder.header("Accept", "application/json")
|
||||
if (auth) {
|
||||
tokens.accessToken?.let { builder.header("Authorization", "Bearer $it") }
|
||||
}
|
||||
|
||||
http.newCall(builder.build()).execute().use { response ->
|
||||
val text = response.body?.string().orEmpty()
|
||||
if (text.isBlank()) {
|
||||
throw ApiException(1500, "服务端返回空响应(HTTP ${response.code})")
|
||||
}
|
||||
val envelope = runCatching { json.decodeFromString<Envelope<T>>(text) }.getOrElse {
|
||||
throw ApiException(1500, "响应格式异常(HTTP ${response.code})")
|
||||
}
|
||||
if (envelope.code != 0) {
|
||||
if (envelope.code == 2002 && auth) tokens.clear()
|
||||
throw ApiException(envelope.code, envelope.message, envelope.requestId)
|
||||
}
|
||||
envelope.data ?: throw ApiException(1500, "响应缺少 data")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.dadao.task.data
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
|
||||
/**
|
||||
* 令牌存储。
|
||||
*
|
||||
* TODO(M5): 换成 EncryptedSharedPreferences / Keystore 包装,明文 SP 只用于 M0 联调。
|
||||
*/
|
||||
class TokenStore(context: Context) {
|
||||
|
||||
private val prefs: SharedPreferences =
|
||||
context.getSharedPreferences("dadao.session", Context.MODE_PRIVATE)
|
||||
|
||||
var accessToken: String?
|
||||
get() = prefs.getString(KEY_ACCESS, null)
|
||||
set(value) = prefs.edit().putString(KEY_ACCESS, value).apply()
|
||||
|
||||
var refreshToken: String?
|
||||
get() = prefs.getString(KEY_REFRESH, null)
|
||||
set(value) = prefs.edit().putString(KEY_REFRESH, value).apply()
|
||||
|
||||
fun save(access: String, refresh: String) {
|
||||
prefs.edit().putString(KEY_ACCESS, access).putString(KEY_REFRESH, refresh).apply()
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
prefs.edit().remove(KEY_ACCESS).remove(KEY_REFRESH).apply()
|
||||
}
|
||||
|
||||
val isLoggedIn: Boolean get() = !accessToken.isNullOrBlank()
|
||||
|
||||
private companion object {
|
||||
const val KEY_ACCESS = "access"
|
||||
const val KEY_REFRESH = "refresh"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package com.dadao.task.ui
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
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.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.dadao.task.data.Api
|
||||
import com.dadao.task.data.ApiException
|
||||
import com.dadao.task.data.Workspace
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
private val ROLE_LABEL = mapOf(
|
||||
"owner" to "所有者",
|
||||
"admin" to "管理员",
|
||||
"member" to "成员",
|
||||
"guest" to "访客",
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun HomeScreen(api: Api, onLogout: () -> Unit) {
|
||||
val workspaces = remember { mutableStateListOf<Workspace>() }
|
||||
var loading by remember { mutableStateOf(true) }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
var showCreate by remember { mutableStateOf(false) }
|
||||
var invitation by remember { mutableStateOf<String?>(null) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
suspend fun reload() {
|
||||
loading = true
|
||||
error = null
|
||||
try {
|
||||
workspaces.clear()
|
||||
workspaces.addAll(api.workspaces())
|
||||
} catch (e: ApiException) {
|
||||
error = e.message
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) { reload() }
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("我的工作区") },
|
||||
actions = { TextButton(onClick = onLogout) { Text("退出") } },
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
FloatingActionButton(onClick = { showCreate = true }) { Text("+") }
|
||||
},
|
||||
) { padding ->
|
||||
Column(Modifier.fillMaxSize().padding(padding).padding(16.dp)) {
|
||||
error?.let {
|
||||
Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
when {
|
||||
loading -> Text("加载中…", style = MaterialTheme.typography.bodyMedium)
|
||||
workspaces.isEmpty() -> Text(
|
||||
"还没有工作区。点右下角 + 新建一个,任务、工单、知识都放在里面。",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
else -> LazyColumn(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
items(workspaces, key = { it.id }) { ws ->
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth().clickable {
|
||||
scope.launch {
|
||||
try {
|
||||
val inv = api.createInvitation(ws.id)
|
||||
invitation = "${ws.name}\n邀请码 ${inv.code}\n有效期至 ${inv.expiresAt}\n\n分享链接:/join/${ws.id}/${inv.code}"
|
||||
} catch (e: ApiException) {
|
||||
error = e.message
|
||||
}
|
||||
}
|
||||
},
|
||||
colors = CardDefaults.cardColors(),
|
||||
) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(16.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column {
|
||||
Text(ws.name, style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
ws.slug,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f),
|
||||
)
|
||||
}
|
||||
Text(
|
||||
ROLE_LABEL[ws.role] ?: (ws.role ?: ""),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = Accent,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
"点工作区可生成邀请码(M0 临时入口,M1 换成工作区详情页)",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.45f),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (showCreate) {
|
||||
var name by remember { mutableStateOf("") }
|
||||
var busy by remember { mutableStateOf(false) }
|
||||
AlertDialog(
|
||||
onDismissRequest = { showCreate = false },
|
||||
title = { Text("新建工作区") },
|
||||
text = {
|
||||
OutlinedTextField(
|
||||
value = name,
|
||||
onValueChange = { name = it.take(40) },
|
||||
label = { Text("名称") },
|
||||
singleLine = true,
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(enabled = !busy && name.isNotBlank(), onClick = {
|
||||
scope.launch {
|
||||
busy = true
|
||||
try {
|
||||
api.createWorkspace(name.trim())
|
||||
showCreate = false
|
||||
reload()
|
||||
} catch (e: ApiException) {
|
||||
error = e.message
|
||||
} finally {
|
||||
busy = false
|
||||
}
|
||||
}
|
||||
}) { Text("创建") }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = { showCreate = false }) { Text("取消") } },
|
||||
)
|
||||
}
|
||||
|
||||
invitation?.let { text ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { invitation = null },
|
||||
title = { Text("邀请成员") },
|
||||
text = { Text(text) },
|
||||
confirmButton = { TextButton(onClick = { invitation = null }) { Text("知道了") } },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package com.dadao.task.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
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.text.KeyboardOptions
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
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.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.dadao.task.data.Api
|
||||
import com.dadao.task.data.ApiException
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun LoginScreen(api: Api, onLoggedIn: () -> Unit) {
|
||||
var phone by remember { mutableStateOf("") }
|
||||
var code by remember { mutableStateOf("") }
|
||||
var devCode by remember { mutableStateOf<String?>(null) }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
var busy by remember { mutableStateOf(false) }
|
||||
var cooldown by remember { mutableStateOf(0) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
fun sendCode() {
|
||||
if (phone.length < 6 || cooldown > 0) return
|
||||
scope.launch {
|
||||
busy = true
|
||||
error = null
|
||||
try {
|
||||
val data = api.sendSms(phone)
|
||||
// 开发环境后端会回显验证码,联调时省事;生产不会返回。
|
||||
devCode = data?.toString()?.let { Regex("\"dev_code\"\\s*:\\s*\"(\\d+)\"").find(it)?.groupValues?.get(1) }
|
||||
cooldown = 60
|
||||
} catch (e: ApiException) {
|
||||
error = e.message
|
||||
} finally {
|
||||
busy = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun submit() {
|
||||
if (phone.isBlank() || code.isBlank()) return
|
||||
scope.launch {
|
||||
busy = true
|
||||
error = null
|
||||
try {
|
||||
api.login(phone, code)
|
||||
onLoggedIn()
|
||||
} catch (e: ApiException) {
|
||||
error = e.message
|
||||
} finally {
|
||||
busy = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().padding(28.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Text("达道Task", style = MaterialTheme.typography.headlineMedium)
|
||||
Text(
|
||||
"从任务到知识,一条道走到底",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f),
|
||||
)
|
||||
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(12.dp))
|
||||
OutlinedTextField(
|
||||
value = code,
|
||||
onValueChange = { code = it.filter(Char::isDigit).take(6) },
|
||||
label = { Text("验证码") },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
trailingIcon = {
|
||||
TextButton(enabled = !busy && cooldown == 0, onClick = { sendCode() }) {
|
||||
Text(if (cooldown > 0) "${cooldown}s" else "获取验证码")
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
devCode?.let {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text("开发环境验证码:$it", style = MaterialTheme.typography.bodySmall, color = Accent)
|
||||
}
|
||||
error?.let {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(20.dp))
|
||||
Button(
|
||||
onClick = { submit() },
|
||||
enabled = !busy && phone.length == 11 && code.isNotBlank(),
|
||||
modifier = Modifier.fillMaxWidth().height(48.dp),
|
||||
) {
|
||||
if (busy) CircularProgressIndicator(Modifier.height(20.dp), strokeWidth = 2.dp)
|
||||
else Text("登录 / 注册")
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text(
|
||||
"未注册的手机号将自动创建账号",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f),
|
||||
modifier = Modifier.align(Alignment.CenterHorizontally),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.dadao.task.ui
|
||||
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
// 与 Web 端 tailwind.config.js 的色板保持一致:墨青主色 + 陶土色点缀。
|
||||
val Ink = Color(0xFF3F5A68)
|
||||
val InkDark = Color(0xFF2C4049)
|
||||
val Accent = Color(0xFFC2703D)
|
||||
val Paper = Color(0xFFF7F8F8)
|
||||
|
||||
private val colors = lightColorScheme(
|
||||
primary = Ink,
|
||||
onPrimary = Color.White,
|
||||
primaryContainer = Color(0xFFDCE6EA),
|
||||
onPrimaryContainer = InkDark,
|
||||
secondary = Accent,
|
||||
onSecondary = Color.White,
|
||||
background = Paper,
|
||||
surface = Color.White,
|
||||
onSurface = Color(0xFF1B2528),
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun DadaoTheme(content: @Composable () -> Unit) {
|
||||
MaterialTheme(colorScheme = colors, typography = Typography(), content = content)
|
||||
}
|
||||
Reference in New Issue
Block a user