- 生产已关短信登录,登录页先读 /auth/methods 再决定入口:默认手机号/邮箱+密码, 后端开着短信时才出现验证码登录;邮箱账号 phone 为空,User.phone 改可空 - 新增邮箱验证码注册(一步建号即登录态) - 知识:分类色点/选中描边/加载态,管理面板可改名、选色(10 色板或手填 hex)、 建子分类、删除(知识回到未分类),卡片上边线用分类色 - 正文 16sp / 行高 1.7,列表标题 SemiBold,与 Web 阅读排版对齐 - README 更新登录方式与结构说明
This commit is contained in:
+5
-3
@@ -79,7 +79,9 @@ release 仍然只允许 `network_security_config.xml` 里列出的域,别把
|
|||||||
adb install -r app/build/outputs/apk/debug/app-debug.apk
|
adb install -r app/build/outputs/apk/debug/app-debug.apk
|
||||||
```
|
```
|
||||||
|
|
||||||
首次打开 → 手机号登录(开发环境验证码在接口响应里回显,形如 `开发环境验证码:123456`)。
|
首次打开 → **手机号或邮箱 + 密码**登录(短信验证码登录默认关闭;开发环境需要验证码登录时,
|
||||||
|
后端设 `INTERNAL_TEST_LOGIN=true`,固定验证码 `123456`,登录页会自动出现「验证码登录」切换)。
|
||||||
|
没有账号就点「用邮箱注册」:收邮箱验证码 → 设密码 → 直接进工作区。
|
||||||
|
|
||||||
## 结构
|
## 结构
|
||||||
|
|
||||||
@@ -91,10 +93,10 @@ app/src/main/java/com/dadao/task/
|
|||||||
│ └── TokenStore.kt 令牌本地存储(TODO:换 EncryptedSharedPreferences)
|
│ └── TokenStore.kt 令牌本地存储(TODO:换 EncryptedSharedPreferences)
|
||||||
└── ui/
|
└── ui/
|
||||||
├── Theme.kt 与 Web 一致的靛蓝 #4F46E5 + 陶土色板
|
├── Theme.kt 与 Web 一致的靛蓝 #4F46E5 + 陶土色板
|
||||||
├── LoginScreen.kt 手机号 + 验证码
|
├── LoginScreen.kt 手机号/邮箱 + 密码(短信登录只在后端开启时出现)、邮箱验证码注册
|
||||||
├── HomeScreen.kt 工作区列表 / 新建 / 生成邀请码
|
├── HomeScreen.kt 工作区列表 / 新建 / 生成邀请码
|
||||||
├── TaskListScreen.kt 任务视图切换、快速新建、勾选完成、详情与评论
|
├── TaskListScreen.kt 任务视图切换、快速新建、勾选完成、详情与评论
|
||||||
└── KnowledgeScreen.kt 知识四态 tab、手记、按状态给动作(发布需 owner/admin)
|
└── KnowledgeScreen.kt 知识四态 tab、手记、按状态给动作(发布需 owner/admin)、分类管理与配色
|
||||||
```
|
```
|
||||||
|
|
||||||
## 模拟器(本机搭建记录,别重复踩)
|
## 模拟器(本机搭建记录,别重复踩)
|
||||||
|
|||||||
@@ -44,10 +44,34 @@ private data class Envelope<T>(
|
|||||||
@Serializable
|
@Serializable
|
||||||
data class User(
|
data class User(
|
||||||
val id: String,
|
val id: String,
|
||||||
val phone: String,
|
/**
|
||||||
|
* 邮箱注册的账号没有手机号;有手机号的也只给脱敏值(138****8000),
|
||||||
|
* 与后端 `_user_out` 一致。所以这里必须可空——否则邮箱账号一登录就解析失败。
|
||||||
|
*/
|
||||||
|
val phone: String? = null,
|
||||||
|
val email: String? = null,
|
||||||
|
@SerialName("email_verified") val emailVerified: Boolean = false,
|
||||||
|
@SerialName("has_password") val hasPassword: Boolean = false,
|
||||||
val nickname: String = "",
|
val nickname: String = "",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 这台服务现在开放哪些登录入口(后端 `/auth/methods`)。
|
||||||
|
*
|
||||||
|
* 生产早就把短信登录关了,客户端不能"照着界面上有就点"——
|
||||||
|
* 点进去才发现这条路关着最气人,所以登录页先问一次再渲染。
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class AuthMethods(
|
||||||
|
val password: Boolean = true,
|
||||||
|
@SerialName("email_password") val emailPassword: Boolean = true,
|
||||||
|
@SerialName("sms_code") val smsCode: Boolean = false,
|
||||||
|
@SerialName("email_register") val emailRegister: Boolean = false,
|
||||||
|
) {
|
||||||
|
/** 一条路都没有时至少让密码登录留在界面上,免得白屏。 */
|
||||||
|
val usable: Boolean get() = password || emailPassword || smsCode
|
||||||
|
}
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class Tokens(
|
data class Tokens(
|
||||||
@SerialName("access_token") val accessToken: String,
|
@SerialName("access_token") val accessToken: String,
|
||||||
@@ -256,9 +280,12 @@ data class AppRelease(
|
|||||||
|
|
||||||
/** 知识分类:工作区级的三级小树,fullPath 形如「研发 / 部署 / 回滚」。 */
|
/** 知识分类:工作区级的三级小树,fullPath 形如「研发 / 部署 / 回滚」。 */
|
||||||
@Serializable
|
@Serializable
|
||||||
data class KnowledgeCategory( val id: String,
|
data class KnowledgeCategory(
|
||||||
|
val id: String,
|
||||||
val name: String = "",
|
val name: String = "",
|
||||||
@SerialName("parent_id") val parentId: String? = null,
|
@SerialName("parent_id") val parentId: String? = null,
|
||||||
|
/** 分类色(#RRGGBB),卡片上边线用它;没设时前端按 id 稳定挑一个默认色。 */
|
||||||
|
val color: String? = null,
|
||||||
val depth: Int = 1,
|
val depth: Int = 1,
|
||||||
@SerialName("sort_order") val sortOrder: Int = 0,
|
@SerialName("sort_order") val sortOrder: Int = 0,
|
||||||
val version: Int = 1,
|
val version: Int = 1,
|
||||||
@@ -324,6 +351,25 @@ private data class SmsRequest(val phone: String)
|
|||||||
@Serializable
|
@Serializable
|
||||||
private data class LoginRequest(val phone: String, val code: String)
|
private data class LoginRequest(val phone: String, val code: String)
|
||||||
|
|
||||||
|
/** 手机号或邮箱二选一:只发一个字段,另一个为 null 时不会被序列化出去。 */
|
||||||
|
@Serializable
|
||||||
|
private data class PasswordLoginRequest(
|
||||||
|
val phone: String? = null,
|
||||||
|
val email: String? = null,
|
||||||
|
val password: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class EmailCodeRequest(val email: String)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class RegisterRequest(
|
||||||
|
val email: String,
|
||||||
|
val code: String,
|
||||||
|
val password: String,
|
||||||
|
val nickname: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
private data class RefreshRequest(@SerialName("refresh_token") val refreshToken: String)
|
private data class RefreshRequest(@SerialName("refresh_token") val refreshToken: String)
|
||||||
|
|
||||||
@@ -370,6 +416,27 @@ private data class KnowledgeCreateRequest(
|
|||||||
@Serializable
|
@Serializable
|
||||||
private data class KnowledgeActionRequest(val action: String)
|
private data class KnowledgeActionRequest(val action: String)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class CategoryCreateRequest(
|
||||||
|
val name: String,
|
||||||
|
@SerialName("parent_id") val parentId: String? = null,
|
||||||
|
@SerialName("sort_order") val sortOrder: Int = 0,
|
||||||
|
val color: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分类改名 / 改色。`version` 是乐观锁,必填。
|
||||||
|
*
|
||||||
|
* color 传空串表示"取消上色",不传(null)表示"别动颜色"——
|
||||||
|
* 靠 `explicitNulls = false` 把 null 字段整个省掉来区分这两件事。
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
private data class CategoryPatchRequest(
|
||||||
|
val version: Int,
|
||||||
|
val name: String? = null,
|
||||||
|
val color: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
private data class TicketCreateRequest(
|
private data class TicketCreateRequest(
|
||||||
val title: String,
|
val title: String,
|
||||||
@@ -475,6 +542,58 @@ class Api(private val tokens: TokenStore) {
|
|||||||
|
|
||||||
suspend fun me(): MeResult = call(path = "/auth/me", auth = true)
|
suspend fun me(): MeResult = call(path = "/auth/me", auth = true)
|
||||||
|
|
||||||
|
/** 这台服务开放哪些登录入口;登录页拿到之后才决定渲染哪几个 tab。 */
|
||||||
|
suspend fun authMethods(): AuthMethods = call(path = "/auth/methods")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 手机号 / 邮箱 + 密码登录。
|
||||||
|
*
|
||||||
|
* 账号里有没有 `@` 决定发哪个字段:后端两个都收,但带着一个空字符串 phone
|
||||||
|
* 会被 pattern 校验挡掉,所以这里只发一个。
|
||||||
|
*/
|
||||||
|
suspend fun loginPassword(account: String, password: String): LoginResult {
|
||||||
|
val who = account.trim()
|
||||||
|
val body = if (who.contains('@')) {
|
||||||
|
PasswordLoginRequest(email = who, password = password)
|
||||||
|
} else {
|
||||||
|
PasswordLoginRequest(phone = who, password = password)
|
||||||
|
}
|
||||||
|
val result: LoginResult = call(
|
||||||
|
path = "/auth/login/password",
|
||||||
|
body = json.encodeToString(body),
|
||||||
|
)
|
||||||
|
tokens.save(result.tokens.accessToken, result.tokens.refreshToken)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 邮箱注册验证码。生产真发信;`dev_code` 只在开了 EMAIL_CODE_DEV_ECHO 的本机才回显。 */
|
||||||
|
suspend fun sendEmailCode(email: String): JsonElement? = call(
|
||||||
|
path = "/auth/email/code",
|
||||||
|
body = json.encodeToString(EmailCodeRequest(email.trim())),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 邮箱 + 验证码 + 密码一步建号,注册完就是登录态(服务端直接给令牌)。 */
|
||||||
|
suspend fun register(
|
||||||
|
email: String,
|
||||||
|
code: String,
|
||||||
|
password: String,
|
||||||
|
nickname: String? = null,
|
||||||
|
): LoginResult {
|
||||||
|
val result: LoginResult = call(
|
||||||
|
path = "/auth/register",
|
||||||
|
body = json.encodeToString(
|
||||||
|
RegisterRequest(
|
||||||
|
email = email.trim(),
|
||||||
|
code = code.trim(),
|
||||||
|
password = password,
|
||||||
|
nickname = nickname?.trim()?.ifBlank { null },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
tokens.save(result.tokens.accessToken, result.tokens.refreshToken)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 列表类接口服务端返回的是 `{"items": [...]}` 而不是裸数组
|
* 列表类接口服务端返回的是 `{"items": [...]}` 而不是裸数组
|
||||||
* (`backend/app/api/workspaces.py` 的 list_workspaces),
|
* (`backend/app/api/workspaces.py` 的 list_workspaces),
|
||||||
@@ -736,6 +855,50 @@ class Api(private val tokens: TokenStore) {
|
|||||||
suspend fun knowledgeCategories(workspaceId: String): KnowledgeCategoryPage =
|
suspend fun knowledgeCategories(workspaceId: String): KnowledgeCategoryPage =
|
||||||
call(path = "/workspaces/$workspaceId/knowledge/categories", auth = true)
|
call(path = "/workspaces/$workspaceId/knowledge/categories", auth = true)
|
||||||
|
|
||||||
|
/** 建分类;同层重名会被服务端拒(4006)。color 不传就是不上色。 */
|
||||||
|
suspend fun createKnowledgeCategory(
|
||||||
|
workspaceId: String,
|
||||||
|
name: String,
|
||||||
|
parentId: String? = null,
|
||||||
|
color: String? = null,
|
||||||
|
): KnowledgeCategory = call(
|
||||||
|
path = "/workspaces/$workspaceId/knowledge/categories",
|
||||||
|
body = json.encodeToString(
|
||||||
|
CategoryCreateRequest(name = name.trim(), parentId = parentId, color = color),
|
||||||
|
),
|
||||||
|
auth = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 改名 / 改色,必须带 version。
|
||||||
|
*
|
||||||
|
* color 传 `""` 表示取消上色,传 null 表示别动颜色——这个区别由
|
||||||
|
* `explicitNulls = false`(null 字段直接不序列化)兜住。
|
||||||
|
*/
|
||||||
|
suspend fun patchKnowledgeCategory(
|
||||||
|
workspaceId: String,
|
||||||
|
categoryId: String,
|
||||||
|
version: Int,
|
||||||
|
name: String? = null,
|
||||||
|
color: String? = null,
|
||||||
|
): KnowledgeCategory = call(
|
||||||
|
path = "/workspaces/$workspaceId/knowledge/categories/$categoryId",
|
||||||
|
body = json.encodeToString(CategoryPatchRequest(version, name?.trim(), color)),
|
||||||
|
auth = true,
|
||||||
|
method = "PATCH",
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 删分类。force=true 才能删有内容的分类,里面的知识会退成未分类。 */
|
||||||
|
suspend fun deleteKnowledgeCategory(
|
||||||
|
workspaceId: String,
|
||||||
|
categoryId: String,
|
||||||
|
force: Boolean = false,
|
||||||
|
): JsonElement = call(
|
||||||
|
path = "/workspaces/$workspaceId/knowledge/categories/$categoryId?force=$force",
|
||||||
|
auth = true,
|
||||||
|
method = "DELETE",
|
||||||
|
)
|
||||||
|
|
||||||
/** 手记:没有来源的知识,得自己写清楚。 */
|
/** 手记:没有来源的知识,得自己写清楚。 */
|
||||||
suspend fun createKnowledge(
|
suspend fun createKnowledge(
|
||||||
workspaceId: String,
|
workspaceId: String,
|
||||||
|
|||||||
@@ -5,10 +5,13 @@ import android.net.Uri
|
|||||||
import android.provider.OpenableColumns
|
import android.provider.OpenableColumns
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.foundation.BorderStroke
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.horizontalScroll
|
import androidx.compose.foundation.horizontalScroll
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
@@ -18,15 +21,19 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.heightIn
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.material3.TextFieldDefaults
|
import androidx.compose.material3.TextFieldDefaults
|
||||||
@@ -39,6 +46,8 @@ import androidx.compose.runtime.rememberCoroutineScope
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.alpha
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
@@ -87,6 +96,76 @@ private fun parseTags(raw: String): List<String> =
|
|||||||
private fun catLabel(cat: KnowledgeCategory): String =
|
private fun catLabel(cat: KnowledgeCategory): String =
|
||||||
"·".repeat((cat.depth - 1).coerceAtLeast(0)) + cat.name
|
"·".repeat((cat.depth - 1).coerceAtLeast(0)) + cat.name
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 与 Web 端同一套调色板。
|
||||||
|
*
|
||||||
|
* 分类没设颜色时按 id 稳定挑一个:老分类不会全灰,也不会每次刷新换个颜色
|
||||||
|
* (用户按颜色记分类,颜色一变等于记忆作废)。
|
||||||
|
*/
|
||||||
|
private val categoryPalette = listOf(
|
||||||
|
"#4F46E5", "#0EA5E9", "#10B981", "#F59E0B", "#EF4444",
|
||||||
|
"#EC4899", "#8B5CF6", "#14B8A6", "#64748B", "#84CC16",
|
||||||
|
)
|
||||||
|
|
||||||
|
private val hexColorRe = Regex("^#[0-9A-Fa-f]{6}$")
|
||||||
|
|
||||||
|
/** 分类色 → Compose Color。没设色就按 id 稳定挑调色板里的一个。 */
|
||||||
|
private fun categoryColorOf(id: String?, color: String?): Color? {
|
||||||
|
val raw = color?.takeIf { hexColorRe.matches(it) } ?: id?.let { key ->
|
||||||
|
var hash = 0
|
||||||
|
key.forEach { hash = (hash * 31 + it.code) % 9973 }
|
||||||
|
categoryPalette[hash % categoryPalette.size]
|
||||||
|
} ?: return null
|
||||||
|
return runCatching { Color(android.graphics.Color.parseColor(raw)) }.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun categoryColorOf(cat: KnowledgeCategory?): Color? =
|
||||||
|
cat?.let { categoryColorOf(it.id, it.color) }
|
||||||
|
|
||||||
|
/** 带色点的分类 chip:颜色是"这个分类是哪个"最快的识别方式。 */
|
||||||
|
@Composable
|
||||||
|
private fun CategoryChip(
|
||||||
|
label: String,
|
||||||
|
color: Color?,
|
||||||
|
selected: Boolean,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.clickable { onClick() },
|
||||||
|
color = if (selected) PrimarySoft else Color.White,
|
||||||
|
contentColor = if (selected) Primary else InkSoft,
|
||||||
|
shape = RoundedCornerShape(8.dp),
|
||||||
|
border = BorderStroke(
|
||||||
|
if (selected) 1.5.dp else 1.dp,
|
||||||
|
if (selected) Primary else Line,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
Modifier.padding(horizontal = 8.dp, vertical = 6.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
if (color != null) {
|
||||||
|
CategoryDot(color, 6.dp)
|
||||||
|
Spacer(Modifier.width(5.dp))
|
||||||
|
}
|
||||||
|
Text(label, style = MaterialTheme.typography.labelSmall)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LoadingInline(text: String = "载入中…") {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(12.dp),
|
||||||
|
strokeWidth = 1.5.dp,
|
||||||
|
color = Primary,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(6.dp))
|
||||||
|
Text(text, style = MaterialTheme.typography.labelSmall, color = Primary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun KnowledgeScreen(
|
fun KnowledgeScreen(
|
||||||
api: Api,
|
api: Api,
|
||||||
@@ -106,6 +185,7 @@ fun KnowledgeScreen(
|
|||||||
var adding by remember { mutableStateOf(false) }
|
var adding by remember { mutableStateOf(false) }
|
||||||
// null = 全部;"none" = 未分类;其它是分类 id(服务端含子分类)
|
// null = 全部;"none" = 未分类;其它是分类 id(服务端含子分类)
|
||||||
var catFilter by remember { mutableStateOf<String?>(null) }
|
var catFilter by remember { mutableStateOf<String?>(null) }
|
||||||
|
var managing by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
suspend fun loadCategories() {
|
suspend fun loadCategories() {
|
||||||
runCatching { api.knowledgeCategories(workspace.id) }
|
runCatching { api.knowledgeCategories(workspace.id) }
|
||||||
@@ -176,27 +256,26 @@ fun KnowledgeScreen(
|
|||||||
Row(
|
Row(
|
||||||
modifier = Modifier.horizontalScroll(rememberScrollState()),
|
modifier = Modifier.horizontalScroll(rememberScrollState()),
|
||||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
SurfaceChip(
|
CategoryChip("全部分类", null, catFilter == null) { catFilter = null }
|
||||||
text = "全部分类",
|
CategoryChip("未分类", null, catFilter == "none") { catFilter = "none" }
|
||||||
selected = catFilter == null,
|
|
||||||
onClick = { catFilter = null },
|
|
||||||
)
|
|
||||||
SurfaceChip(
|
|
||||||
text = "未分类",
|
|
||||||
selected = catFilter == "none",
|
|
||||||
onClick = { catFilter = "none" },
|
|
||||||
)
|
|
||||||
categories.forEach { cat ->
|
categories.forEach { cat ->
|
||||||
SurfaceChip(
|
CategoryChip(catLabel(cat), categoryColorOf(cat), catFilter == cat.id) {
|
||||||
text = catLabel(cat),
|
catFilter = cat.id
|
||||||
selected = catFilter == cat.id,
|
}
|
||||||
onClick = { catFilter = cat.id },
|
}
|
||||||
)
|
if (loading) {
|
||||||
|
Spacer(Modifier.width(4.dp))
|
||||||
|
LoadingInline()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Spacer(Modifier.height(10.dp))
|
Spacer(Modifier.height(10.dp))
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
GhostButton("记一条", onClick = { adding = true })
|
GhostButton("记一条", onClick = { adding = true })
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
GhostButton("管理分类", onClick = { managing = true })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,7 +300,12 @@ fun KnowledgeScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
items(docs, key = { it.id }) { doc ->
|
items(docs, key = { it.id }) { doc ->
|
||||||
SoftCard(padding = 12.dp, onClick = { open = doc }) {
|
SoftCard(
|
||||||
|
padding = 12.dp,
|
||||||
|
accent = categoryColorOf(doc.category),
|
||||||
|
modifier = Modifier.alpha(if (loading) 0.45f else 1f),
|
||||||
|
onClick = { open = doc },
|
||||||
|
) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
SoftPill(docTypeLabel(doc.knowledge.docType))
|
SoftPill(docTypeLabel(doc.knowledge.docType))
|
||||||
Spacer(Modifier.width(6.dp))
|
Spacer(Modifier.width(6.dp))
|
||||||
@@ -235,16 +319,27 @@ fun KnowledgeScreen(
|
|||||||
Text(
|
Text(
|
||||||
doc.title,
|
doc.title,
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.SemiBold,
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(6.dp))
|
Spacer(Modifier.height(6.dp))
|
||||||
val meta = buildList {
|
// 分类用同色圆点带出来:卡片上边线看颜色,这里看名字,两处一致
|
||||||
doc.category?.let { add(it.fullPath.ifBlank { it.name }) }
|
doc.category?.let { cat ->
|
||||||
doc.tags.forEach { add("#${it.name}") }
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
categoryColorOf(cat)?.let {
|
||||||
|
CategoryDot(it, 6.dp)
|
||||||
|
Spacer(Modifier.width(5.dp))
|
||||||
}
|
}
|
||||||
if (meta.isNotEmpty()) {
|
|
||||||
Text(
|
Text(
|
||||||
meta.joinToString(" "),
|
cat.fullPath.ifBlank { cat.name },
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = InkSoft,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
}
|
||||||
|
if (doc.tags.isNotEmpty()) {
|
||||||
|
Text(
|
||||||
|
doc.tags.joinToString(" ") { "#${it.name}" },
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
color = Primary,
|
color = Primary,
|
||||||
)
|
)
|
||||||
@@ -307,6 +402,373 @@ fun KnowledgeScreen(
|
|||||||
onFail = { fail(it) },
|
onFail = { fail(it) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (managing) {
|
||||||
|
CategoryManagerDialog(
|
||||||
|
api = api,
|
||||||
|
workspaceId = workspace.id,
|
||||||
|
categories = categories,
|
||||||
|
onDismiss = { managing = false },
|
||||||
|
onChanged = {
|
||||||
|
scope.launch {
|
||||||
|
loadCategories()
|
||||||
|
reload()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onFail = { fail(it) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 分类编辑中的草稿:id 为空表示"还没建出来的新分类"。 */
|
||||||
|
private data class CategoryDraft(
|
||||||
|
val id: String?,
|
||||||
|
val parentId: String?,
|
||||||
|
val name: String,
|
||||||
|
val color: String?,
|
||||||
|
val version: Int,
|
||||||
|
val depth: Int,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun hexToColor(hex: String): Color =
|
||||||
|
runCatching { Color(android.graphics.Color.parseColor(hex)) }.getOrDefault(InkMuted)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分类管理:改名 / 改色 / 建子分类 / 删除。
|
||||||
|
*
|
||||||
|
* 手机上不放浮层菜单——点分类行直接进编辑,所有动作都在同一张卡片里,
|
||||||
|
* 免得"改名在哪、删在哪"到处找。
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun CategoryManagerDialog(
|
||||||
|
api: Api,
|
||||||
|
workspaceId: String,
|
||||||
|
categories: List<KnowledgeCategory>,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onChanged: () -> Unit,
|
||||||
|
onFail: (Throwable) -> Unit,
|
||||||
|
) {
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
var items by remember { mutableStateOf(categories) }
|
||||||
|
var draft by remember { mutableStateOf<CategoryDraft?>(null) }
|
||||||
|
var pendingDelete by remember { mutableStateOf<KnowledgeCategory?>(null) }
|
||||||
|
var busy by remember { mutableStateOf(false) }
|
||||||
|
var notice by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
|
suspend fun refresh() {
|
||||||
|
runCatching { api.knowledgeCategories(workspaceId) }
|
||||||
|
.onSuccess {
|
||||||
|
items = it.items
|
||||||
|
onChanged()
|
||||||
|
}
|
||||||
|
.onFailure { onFail(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun fail(e: Throwable) {
|
||||||
|
val code = (e as? ApiException)?.code ?: 0
|
||||||
|
notice = verifyStatusCode(code).ifBlank { e.message ?: "操作没成功" }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun save() {
|
||||||
|
val d = draft ?: return
|
||||||
|
val name = d.name.trim()
|
||||||
|
if (name.isEmpty()) {
|
||||||
|
notice = "先给分类起个名字"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (d.color != null && !hexColorRe.matches(d.color)) {
|
||||||
|
notice = "颜色要用 #RRGGBB,例如 #4F46E5"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
scope.launch {
|
||||||
|
busy = true
|
||||||
|
// 没设色时传空串,表示"这个分类不上色";有颜色就传它
|
||||||
|
val color = d.color ?: ""
|
||||||
|
runCatching {
|
||||||
|
if (d.id == null) {
|
||||||
|
api.createKnowledgeCategory(workspaceId, name, d.parentId, color.ifBlank { null })
|
||||||
|
} else {
|
||||||
|
api.patchKnowledgeCategory(workspaceId, d.id, d.version, name, color)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onSuccess {
|
||||||
|
notice = if (d.id == null) "分类已建好" else "已保存"
|
||||||
|
draft = null
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
.onFailure { fail(it) }
|
||||||
|
busy = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Dialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth(0.94f)
|
||||||
|
.fillMaxHeight(0.9f)
|
||||||
|
.background(Paper, RoundedCornerShape(16.dp))
|
||||||
|
.padding(16.dp),
|
||||||
|
) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(
|
||||||
|
"分类管理",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
GhostButton("关闭", onClick = onDismiss)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
GhostButton(
|
||||||
|
"新建一级分类",
|
||||||
|
enabled = !busy,
|
||||||
|
onClick = {
|
||||||
|
notice = null
|
||||||
|
draft = CategoryDraft(null, null, "", null, 1, 1)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
"分类颜色决定卡片上边线,最多 3 层",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = InkMuted,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val hint = notice
|
||||||
|
if (hint != null) {
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
SoftPill(hint, Primary, PrimarySoft, Modifier.fillMaxWidth())
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(10.dp))
|
||||||
|
Column(Modifier.weight(1f).verticalScroll(rememberScrollState())) {
|
||||||
|
val editing = draft
|
||||||
|
if (editing != null) {
|
||||||
|
CategoryEditorCard(
|
||||||
|
draft = editing,
|
||||||
|
busy = busy,
|
||||||
|
onChange = { draft = it },
|
||||||
|
onSave = { save() },
|
||||||
|
onAddChild = {
|
||||||
|
draft = CategoryDraft(
|
||||||
|
// 新分类还没有 id,挂到当前行的父级下;已有分类就挂在它自己下
|
||||||
|
id = null,
|
||||||
|
parentId = editing.id,
|
||||||
|
name = "",
|
||||||
|
color = editing.color,
|
||||||
|
version = 1,
|
||||||
|
depth = editing.depth + 1,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onDelete = {
|
||||||
|
val id = editing.id
|
||||||
|
val cat = items.firstOrNull { it.id == id }
|
||||||
|
if (cat != null) pendingDelete = cat
|
||||||
|
},
|
||||||
|
onCancel = { draft = null },
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (items.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
"还没有分类。建一个吧——比如「研发」「客户」「流程」。",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = InkMuted,
|
||||||
|
modifier = Modifier.padding(vertical = 10.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
items.forEach { cat ->
|
||||||
|
Row(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable {
|
||||||
|
notice = null
|
||||||
|
draft = CategoryDraft(
|
||||||
|
cat.id,
|
||||||
|
cat.parentId,
|
||||||
|
cat.name,
|
||||||
|
cat.color,
|
||||||
|
cat.version,
|
||||||
|
cat.depth,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.padding(start = ((cat.depth - 1) * 14).dp, top = 9.dp, bottom = 9.dp, end = 4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
CategoryDot(categoryColorOf(cat) ?: LineStrong, 9.dp)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
cat.name,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"${cat.docCount} 条",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = InkMuted,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
"编辑",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = Primary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
DividerLine()
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(20.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val victim = pendingDelete
|
||||||
|
if (victim != null) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { pendingDelete = null },
|
||||||
|
title = { Text("删除「${victim.name}」?") },
|
||||||
|
text = {
|
||||||
|
Text(
|
||||||
|
if (victim.docCount > 0) {
|
||||||
|
"这个分类下还有 ${victim.docCount} 条知识,它们会回到「未分类」,知识本身不会被删。"
|
||||||
|
} else {
|
||||||
|
"这个分类下没有知识,可以放心删。"
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = {
|
||||||
|
val target = victim
|
||||||
|
pendingDelete = null
|
||||||
|
scope.launch {
|
||||||
|
busy = true
|
||||||
|
runCatching {
|
||||||
|
api.deleteKnowledgeCategory(workspaceId, target.id, force = true)
|
||||||
|
}
|
||||||
|
.onSuccess {
|
||||||
|
notice = "已删除「${target.name}」"
|
||||||
|
draft = null
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
.onFailure { fail(it) }
|
||||||
|
busy = false
|
||||||
|
}
|
||||||
|
}) { Text("删除") }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { pendingDelete = null }) { Text("取消") }
|
||||||
|
},
|
||||||
|
containerColor = Paper,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 一个分类的编辑卡:名字、颜色(色板 + 手填 hex)、建子分类、删除。 */
|
||||||
|
@Composable
|
||||||
|
private fun CategoryEditorCard(
|
||||||
|
draft: CategoryDraft,
|
||||||
|
busy: Boolean,
|
||||||
|
onChange: (CategoryDraft) -> Unit,
|
||||||
|
onSave: () -> Unit,
|
||||||
|
onAddChild: () -> Unit,
|
||||||
|
onDelete: () -> Unit,
|
||||||
|
onCancel: () -> Unit,
|
||||||
|
) {
|
||||||
|
SoftCard(padding = 12.dp, accent = categoryColorOf(draft.id, draft.color)) {
|
||||||
|
Text(
|
||||||
|
if (draft.id == null) "新建分类" else "编辑「${draft.name.ifBlank { "未命名" }}」",
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
OutlinedTextField(
|
||||||
|
value = draft.name,
|
||||||
|
onValueChange = { onChange(draft.copy(name = it.take(40))) },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
placeholder = { Text("分类名,同层不能重名", color = InkMuted) },
|
||||||
|
singleLine = true,
|
||||||
|
shape = RoundedCornerShape(10.dp),
|
||||||
|
colors = TextFieldDefaults.colors(
|
||||||
|
focusedContainerColor = Color.White,
|
||||||
|
unfocusedContainerColor = Color.White,
|
||||||
|
focusedIndicatorColor = Primary,
|
||||||
|
unfocusedIndicatorColor = Line,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(10.dp))
|
||||||
|
SectionLabel("颜色(卡片上边线用它)")
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.horizontalScroll(rememberScrollState()),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
categoryPalette.forEach { hex ->
|
||||||
|
val picked = draft.color?.equals(hex, ignoreCase = true) == true
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.size(26.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(hexToColor(hex))
|
||||||
|
.border(
|
||||||
|
if (picked) 2.5.dp else 1.dp,
|
||||||
|
if (picked) Ink else Line,
|
||||||
|
CircleShape,
|
||||||
|
)
|
||||||
|
.clickable { onChange(draft.copy(color = hex)) },
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
if (picked) Text("✓", style = MaterialTheme.typography.labelSmall, color = Color.White)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.clickable { onChange(draft.copy(color = null)) },
|
||||||
|
color = if (draft.color == null) PrimarySoft else Color.White,
|
||||||
|
contentColor = if (draft.color == null) Primary else InkSoft,
|
||||||
|
shape = RoundedCornerShape(8.dp),
|
||||||
|
border = BorderStroke(1.dp, if (draft.color == null) Primary else Line),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"不上色",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
modifier = Modifier.padding(horizontal = 8.dp, vertical = 5.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
OutlinedTextField(
|
||||||
|
value = draft.color.orEmpty(),
|
||||||
|
onValueChange = { onChange(draft.copy(color = it.trim().take(7).ifBlank { null })) },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
placeholder = { Text("也可以手填 #RRGGBB", color = InkMuted) },
|
||||||
|
singleLine = true,
|
||||||
|
shape = RoundedCornerShape(10.dp),
|
||||||
|
colors = TextFieldDefaults.colors(
|
||||||
|
focusedContainerColor = Color.White,
|
||||||
|
unfocusedContainerColor = Color.White,
|
||||||
|
focusedIndicatorColor = Primary,
|
||||||
|
unfocusedIndicatorColor = Line,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(10.dp))
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
PrimaryButton("保存", enabled = !busy, onClick = onSave)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
GhostButton("取消", enabled = !busy, onClick = onCancel)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
if (draft.id != null && draft.depth < 3) {
|
||||||
|
GhostButton("加子分类", enabled = !busy, onClick = onAddChild)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
}
|
||||||
|
if (draft.id != null) {
|
||||||
|
TextButton(enabled = !busy, onClick = onDelete) {
|
||||||
|
Text("删除", color = Rust)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -327,8 +789,9 @@ private fun KnowledgeBody(text: String) {
|
|||||||
factory = { ctx ->
|
factory = { ctx ->
|
||||||
android.widget.TextView(ctx).apply {
|
android.widget.TextView(ctx).apply {
|
||||||
setTextIsSelectable(true)
|
setTextIsSelectable(true)
|
||||||
textSize = 14f
|
// 与 Web 阅读页对齐:正文 16sp、行高 1.7 —— 手机上读长文,14sp 太挤
|
||||||
setLineSpacing(0f, 1.5f)
|
textSize = 16f
|
||||||
|
setLineSpacing(0f, 1.7f)
|
||||||
setTextColor(android.graphics.Color.parseColor("#1E293B"))
|
setTextColor(android.graphics.Color.parseColor("#1E293B"))
|
||||||
markwon.setMarkdown(this, text)
|
markwon.setMarkdown(this, text)
|
||||||
}
|
}
|
||||||
@@ -574,7 +1037,8 @@ private fun KnowledgeDetailDialog(
|
|||||||
},
|
},
|
||||||
text = {
|
text = {
|
||||||
Column(
|
Column(
|
||||||
Modifier.heightIn(max = 460.dp).verticalScroll(rememberScrollState()),
|
// 阅读区给足高度:手机上一屏能看二十来行,不用一直往里滚
|
||||||
|
Modifier.heightIn(min = 320.dp, max = 560.dp).verticalScroll(rememberScrollState()),
|
||||||
) {
|
) {
|
||||||
Text(sourceLine(doc), style = MaterialTheme.typography.labelSmall, color = InkMuted)
|
Text(sourceLine(doc), style = MaterialTheme.typography.labelSmall, color = InkMuted)
|
||||||
Spacer(Modifier.height(10.dp))
|
Spacer(Modifier.height(10.dp))
|
||||||
|
|||||||
@@ -6,11 +6,12 @@ import androidx.compose.foundation.layout.Box
|
|||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.statusBarsPadding
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
@@ -19,6 +20,7 @@ import androidx.compose.foundation.verticalScroll
|
|||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.material3.TextFieldDefaults
|
import androidx.compose.material3.TextFieldDefaults
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
@@ -32,23 +34,45 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.window.Dialog
|
||||||
|
import androidx.compose.ui.window.DialogProperties
|
||||||
import com.dadao.task.data.Api
|
import com.dadao.task.data.Api
|
||||||
import com.dadao.task.data.ApiException
|
import com.dadao.task.data.ApiException
|
||||||
|
import com.dadao.task.data.AuthMethods
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.serialization.json.jsonObject
|
import kotlinx.serialization.json.jsonObject
|
||||||
import kotlinx.serialization.json.jsonPrimitive
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
|
||||||
|
/** 手机号 11 位、邮箱只要有 @ 就放行——最终格式由服务端判,前端别提前拦。 */
|
||||||
|
private fun looksLikeEmail(input: String): Boolean = input.contains('@')
|
||||||
|
|
||||||
|
private fun accountReady(account: String): Boolean {
|
||||||
|
val text = account.trim()
|
||||||
|
return if (looksLikeEmail(text)) text.length >= 6 else text.length == 11
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun LoginScreen(api: Api, onLoggedIn: () -> Unit) {
|
fun LoginScreen(api: Api, onLoggedIn: () -> Unit) {
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
// 登录入口以服务端为准:生产早就关了短信登录,界面不能还摆着一条走不通的路。
|
||||||
|
var methods by remember { mutableStateOf<AuthMethods?>(null) }
|
||||||
|
var mode by remember { mutableStateOf("password") }
|
||||||
|
|
||||||
|
var account by remember { mutableStateOf("") }
|
||||||
|
var password by remember { mutableStateOf("") }
|
||||||
|
|
||||||
var phone by remember { mutableStateOf("") }
|
var phone by remember { mutableStateOf("") }
|
||||||
var code by remember { mutableStateOf("") }
|
var code by remember { mutableStateOf("") }
|
||||||
var seconds by remember { mutableStateOf(0) }
|
var seconds by remember { mutableStateOf(0) }
|
||||||
var devCode by remember { mutableStateOf<String?>(null) }
|
var devCode by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
var busy by remember { mutableStateOf(false) }
|
var busy by remember { mutableStateOf(false) }
|
||||||
var error by remember { mutableStateOf<String?>(null) }
|
var error by remember { mutableStateOf<String?>(null) }
|
||||||
|
var registerOpen by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
// 后端地址可改:包里默认是模拟器地址 10.0.2.2,真机必须换成电脑的局域网地址。
|
// 后端地址可改:包里默认是模拟器地址 10.0.2.2,真机必须换成电脑的局域网地址。
|
||||||
var serverOpen by remember { mutableStateOf(false) }
|
var serverOpen by remember { mutableStateOf(false) }
|
||||||
@@ -86,6 +110,15 @@ fun LoginScreen(api: Api, onLoggedIn: () -> Unit) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
runCatching { api.authMethods() }
|
||||||
|
.onSuccess {
|
||||||
|
methods = it
|
||||||
|
// 只有短信这一条路时,默认切到它,别让人对着密码框发呆
|
||||||
|
if (!it.password && !it.emailPassword && it.smsCode) mode = "code"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
LaunchedEffect(seconds) {
|
LaunchedEffect(seconds) {
|
||||||
if (seconds > 0) {
|
if (seconds > 0) {
|
||||||
delay(1000)
|
delay(1000)
|
||||||
@@ -93,6 +126,25 @@ fun LoginScreen(api: Api, onLoggedIn: () -> Unit) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun report(e: Throwable, fallback: String) {
|
||||||
|
val c = (e as? ApiException)?.code ?: 0
|
||||||
|
if (c == 1) serverOpen = true
|
||||||
|
error = verifyStatusCode(c).ifBlank { e.message ?: fallback }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun submitPassword() {
|
||||||
|
scope.launch {
|
||||||
|
busy = true
|
||||||
|
runCatching { api.loginPassword(account, password) }
|
||||||
|
.onSuccess {
|
||||||
|
error = null
|
||||||
|
onLoggedIn()
|
||||||
|
}
|
||||||
|
.onFailure { report(it, "登录没成功") }
|
||||||
|
busy = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun send() {
|
fun send() {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
busy = true
|
busy = true
|
||||||
@@ -102,16 +154,12 @@ fun LoginScreen(api: Api, onLoggedIn: () -> Unit) {
|
|||||||
devCode = data?.jsonObject?.get("dev_code")?.jsonPrimitive?.content
|
devCode = data?.jsonObject?.get("dev_code")?.jsonPrimitive?.content
|
||||||
error = null
|
error = null
|
||||||
}
|
}
|
||||||
.onFailure {
|
.onFailure { report(it, "验证码没发出去") }
|
||||||
val c = (it as? ApiException)?.code ?: 0
|
|
||||||
if (c == 1) serverOpen = true
|
|
||||||
error = verifyStatusCode(c).ifBlank { it.message ?: "验证码没发出去" }
|
|
||||||
}
|
|
||||||
busy = false
|
busy = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun submit() {
|
fun submitCode() {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
busy = true
|
busy = true
|
||||||
runCatching { api.login(phone.trim(), code.trim()) }
|
runCatching { api.login(phone.trim(), code.trim()) }
|
||||||
@@ -119,15 +167,14 @@ fun LoginScreen(api: Api, onLoggedIn: () -> Unit) {
|
|||||||
error = null
|
error = null
|
||||||
onLoggedIn()
|
onLoggedIn()
|
||||||
}
|
}
|
||||||
.onFailure {
|
.onFailure { report(it, "登录没成功") }
|
||||||
val c = (it as? ApiException)?.code ?: 0
|
|
||||||
if (c == 1) serverOpen = true
|
|
||||||
error = verifyStatusCode(c).ifBlank { it.message ?: "登录没成功" }
|
|
||||||
}
|
|
||||||
busy = false
|
busy = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val smsAvailable = methods?.smsCode == true
|
||||||
|
val registerAvailable = methods?.emailRegister == true
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
@@ -160,12 +207,70 @@ fun LoginScreen(api: Api, onLoggedIn: () -> Unit) {
|
|||||||
Text("登录", style = MaterialTheme.typography.headlineMedium)
|
Text("登录", style = MaterialTheme.typography.headlineMedium)
|
||||||
Spacer(Modifier.height(6.dp))
|
Spacer(Modifier.height(6.dp))
|
||||||
Text(
|
Text(
|
||||||
"手机号 + 验证码,不用记密码。第一次登录会自动建好账号。",
|
if (smsAvailable) {
|
||||||
|
"手机号或邮箱 + 密码,也可以收验证码登录。"
|
||||||
|
} else {
|
||||||
|
"手机号或邮箱 + 密码。第一次来先注册一个账号。"
|
||||||
|
},
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = InkMuted,
|
color = InkMuted,
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(Modifier.height(22.dp))
|
// 两条路都可以走时才给切换 tab,只有一条就别摆出来当摆设
|
||||||
|
if (smsAvailable) {
|
||||||
|
Spacer(Modifier.height(14.dp))
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
SurfaceChip(
|
||||||
|
text = "密码登录",
|
||||||
|
selected = mode == "password",
|
||||||
|
onClick = { mode = "password"; error = null },
|
||||||
|
)
|
||||||
|
SurfaceChip(
|
||||||
|
text = "验证码登录",
|
||||||
|
selected = mode == "code",
|
||||||
|
onClick = { mode = "code"; error = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(18.dp))
|
||||||
|
if (mode == "password") {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = account,
|
||||||
|
onValueChange = { account = it.take(80) },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
label = { Text("手机号或邮箱") },
|
||||||
|
placeholder = { Text("13800138000 或 you@example.com", color = InkMuted) },
|
||||||
|
singleLine = true,
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
keyboardType = if (looksLikeEmail(account)) KeyboardType.Email else KeyboardType.Text,
|
||||||
|
),
|
||||||
|
colors = TextFieldDefaults.colors(
|
||||||
|
focusedContainerColor = Color.White,
|
||||||
|
unfocusedContainerColor = Color.White,
|
||||||
|
focusedIndicatorColor = Primary,
|
||||||
|
unfocusedIndicatorColor = Line,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
OutlinedTextField(
|
||||||
|
value = password,
|
||||||
|
onValueChange = { password = it.take(64) },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
label = { Text("密码") },
|
||||||
|
singleLine = true,
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||||
|
colors = TextFieldDefaults.colors(
|
||||||
|
focusedContainerColor = Color.White,
|
||||||
|
unfocusedContainerColor = Color.White,
|
||||||
|
focusedIndicatorColor = Primary,
|
||||||
|
unfocusedIndicatorColor = Line,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = phone,
|
value = phone,
|
||||||
onValueChange = { phone = it.filter { c -> c.isDigit() }.take(11) },
|
onValueChange = { phone = it.filter { c -> c.isDigit() }.take(11) },
|
||||||
@@ -218,6 +323,7 @@ fun LoginScreen(api: Api, onLoggedIn: () -> Unit) {
|
|||||||
Modifier.fillMaxWidth(),
|
Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val notice = error
|
val notice = error
|
||||||
if (notice != null) {
|
if (notice != null) {
|
||||||
@@ -226,12 +332,36 @@ fun LoginScreen(api: Api, onLoggedIn: () -> Unit) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Spacer(Modifier.height(22.dp))
|
Spacer(Modifier.height(22.dp))
|
||||||
|
if (mode == "password") {
|
||||||
|
PrimaryButton(
|
||||||
|
text = if (busy) "请稍候…" else "登录",
|
||||||
|
enabled = accountReady(account) && password.isNotEmpty() && !busy,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
onClick = { submitPassword() },
|
||||||
|
)
|
||||||
|
} else {
|
||||||
PrimaryButton(
|
PrimaryButton(
|
||||||
text = if (busy) "请稍候…" else "登录",
|
text = if (busy) "请稍候…" else "登录",
|
||||||
enabled = phone.length == 11 && code.length >= 4 && !busy,
|
enabled = phone.length == 11 && code.length >= 4 && !busy,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
onClick = { submit() },
|
onClick = { submitCode() },
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (registerAvailable) {
|
||||||
|
Spacer(Modifier.height(10.dp))
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(
|
||||||
|
"还没有账号?",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = InkMuted,
|
||||||
|
)
|
||||||
|
TextButton(
|
||||||
|
enabled = !busy,
|
||||||
|
onClick = { error = null; registerOpen = true },
|
||||||
|
) { Text("用邮箱注册") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Spacer(Modifier.height(14.dp))
|
Spacer(Modifier.height(14.dp))
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
@@ -338,4 +468,190 @@ fun LoginScreen(api: Api, onLoggedIn: () -> Unit) {
|
|||||||
}
|
}
|
||||||
Spacer(Modifier.height(40.dp))
|
Spacer(Modifier.height(40.dp))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (registerOpen) {
|
||||||
|
RegisterDialog(
|
||||||
|
api = api,
|
||||||
|
onDismiss = { registerOpen = false },
|
||||||
|
onRegistered = {
|
||||||
|
registerOpen = false
|
||||||
|
onLoggedIn()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 邮箱注册:验证码 + 密码,一步建号。
|
||||||
|
*
|
||||||
|
* 注册完服务端直接回令牌,所以这里不再跳回登录页——落地即登录,少一次输入。
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun RegisterDialog(api: Api, onDismiss: () -> Unit, onRegistered: () -> Unit) {
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
var email by remember { mutableStateOf("") }
|
||||||
|
var code by remember { mutableStateOf("") }
|
||||||
|
var password by remember { mutableStateOf("") }
|
||||||
|
var nickname by remember { mutableStateOf("") }
|
||||||
|
var seconds by remember { mutableStateOf(0) }
|
||||||
|
var devCode by remember { mutableStateOf<String?>(null) }
|
||||||
|
var busy by remember { mutableStateOf(false) }
|
||||||
|
var error by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
|
LaunchedEffect(seconds) {
|
||||||
|
if (seconds > 0) {
|
||||||
|
delay(1000)
|
||||||
|
seconds -= 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun fail(e: Throwable, fallback: String) {
|
||||||
|
val c = (e as? ApiException)?.code ?: 0
|
||||||
|
error = verifyStatusCode(c).ifBlank { e.message ?: fallback }
|
||||||
|
}
|
||||||
|
|
||||||
|
Dialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth(0.94f)
|
||||||
|
.fillMaxHeight(0.9f)
|
||||||
|
.background(Paper, RoundedCornerShape(16.dp))
|
||||||
|
.padding(16.dp),
|
||||||
|
) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(
|
||||||
|
"注册",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
GhostButton("取消", onClick = onDismiss)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(10.dp))
|
||||||
|
Column(Modifier.weight(1f).verticalScroll(rememberScrollState())) {
|
||||||
|
Text(
|
||||||
|
"用邮箱收一个验证码,设好密码就是账号。邮箱也是以后找回密码的唯一途径,别填错。",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = InkMuted,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(14.dp))
|
||||||
|
OutlinedTextField(
|
||||||
|
value = email,
|
||||||
|
onValueChange = { email = it.take(80) },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
label = { Text("邮箱") },
|
||||||
|
placeholder = { Text("you@example.com", color = InkMuted) },
|
||||||
|
singleLine = true,
|
||||||
|
shape = RoundedCornerShape(10.dp),
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
|
||||||
|
colors = TextFieldDefaults.colors(
|
||||||
|
focusedContainerColor = Color.White,
|
||||||
|
unfocusedContainerColor = Color.White,
|
||||||
|
focusedIndicatorColor = Primary,
|
||||||
|
unfocusedIndicatorColor = Line,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(10.dp))
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = code,
|
||||||
|
onValueChange = { code = it.filter { c -> c.isDigit() }.take(6) },
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
label = { Text("验证码") },
|
||||||
|
singleLine = true,
|
||||||
|
shape = RoundedCornerShape(10.dp),
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword),
|
||||||
|
colors = TextFieldDefaults.colors(
|
||||||
|
focusedContainerColor = Color.White,
|
||||||
|
unfocusedContainerColor = Color.White,
|
||||||
|
focusedIndicatorColor = Primary,
|
||||||
|
unfocusedIndicatorColor = Line,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(10.dp))
|
||||||
|
GhostButton(
|
||||||
|
text = if (seconds > 0) "${seconds}s" else "发验证码",
|
||||||
|
enabled = email.contains('@') && seconds == 0 && !busy,
|
||||||
|
onClick = {
|
||||||
|
scope.launch {
|
||||||
|
busy = true
|
||||||
|
runCatching { api.sendEmailCode(email) }
|
||||||
|
.onSuccess {
|
||||||
|
seconds = 60
|
||||||
|
devCode = it?.jsonObject
|
||||||
|
?.get("dev_code")
|
||||||
|
?.jsonPrimitive
|
||||||
|
?.content
|
||||||
|
error = null
|
||||||
|
}
|
||||||
|
.onFailure { fail(it, "验证码没发出去") }
|
||||||
|
busy = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val hint = devCode
|
||||||
|
if (hint != null) {
|
||||||
|
Spacer(Modifier.height(10.dp))
|
||||||
|
SoftPill("开发环境验证码:$hint", Primary, PrimarySoft, Modifier.fillMaxWidth())
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(10.dp))
|
||||||
|
OutlinedTextField(
|
||||||
|
value = password,
|
||||||
|
onValueChange = { password = it.take(64) },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
label = { Text("密码") },
|
||||||
|
placeholder = { Text("至少 8 位,别用生日", color = InkMuted) },
|
||||||
|
singleLine = true,
|
||||||
|
shape = RoundedCornerShape(10.dp),
|
||||||
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||||
|
colors = TextFieldDefaults.colors(
|
||||||
|
focusedContainerColor = Color.White,
|
||||||
|
unfocusedContainerColor = Color.White,
|
||||||
|
focusedIndicatorColor = Primary,
|
||||||
|
unfocusedIndicatorColor = Line,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(10.dp))
|
||||||
|
OutlinedTextField(
|
||||||
|
value = nickname,
|
||||||
|
onValueChange = { nickname = it.take(32) },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
label = { Text("称呼(可不填)") },
|
||||||
|
placeholder = { Text("别人在成员列表里看到的名字", color = InkMuted) },
|
||||||
|
singleLine = true,
|
||||||
|
shape = RoundedCornerShape(10.dp),
|
||||||
|
colors = TextFieldDefaults.colors(
|
||||||
|
focusedContainerColor = Color.White,
|
||||||
|
unfocusedContainerColor = Color.White,
|
||||||
|
focusedIndicatorColor = Primary,
|
||||||
|
unfocusedIndicatorColor = Line,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val notice = error
|
||||||
|
if (notice != null) {
|
||||||
|
Spacer(Modifier.height(10.dp))
|
||||||
|
SoftPill(notice, Rust, RustSoft, Modifier.fillMaxWidth())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
PrimaryButton(
|
||||||
|
text = if (busy) "请稍候…" else "注册并登录",
|
||||||
|
enabled = email.contains('@') && code.length >= 4 && password.isNotEmpty() && !busy,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
onClick = {
|
||||||
|
scope.launch {
|
||||||
|
busy = true
|
||||||
|
runCatching { api.register(email, code, password, nickname) }
|
||||||
|
.onSuccess { onRegistered() }
|
||||||
|
.onFailure { fail(it, "注册没成功") }
|
||||||
|
busy = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -299,7 +299,7 @@ private fun TaskRow(item: Item, onOpen: () -> Unit, onToggle: () -> Unit) {
|
|||||||
Text(
|
Text(
|
||||||
item.title,
|
item.title,
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.SemiBold,
|
||||||
color = if (item.isDone) InkMuted else Ink,
|
color = if (item.isDone) InkMuted else Ink,
|
||||||
textDecoration = if (item.isDone) TextDecoration.LineThrough else TextDecoration.None,
|
textDecoration = if (item.isDone) TextDecoration.LineThrough else TextDecoration.None,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -164,7 +164,7 @@ fun TicketsScreen(
|
|||||||
Text(
|
Text(
|
||||||
ticket.title,
|
ticket.title,
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.SemiBold,
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(6.dp))
|
Spacer(Modifier.height(6.dp))
|
||||||
Row(
|
Row(
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import androidx.compose.material3.Text
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
@@ -96,26 +97,45 @@ fun SoftPill(text: String, fg: Color = InkSoft, bg: Color = PaperSunken, modifie
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 卡片:白底、细描边、几乎不可见的投影。整个 App 用它承载所有内容。 */
|
/**
|
||||||
|
* 卡片:白底、细描边、几乎不可见的投影。整个 App 用它承载所有内容。
|
||||||
|
*
|
||||||
|
* [accent] 是卡片顶部的一条 3dp 色带——知识卡片用它显示所属分类的颜色,
|
||||||
|
* 扫一眼列表就能按颜色找回"这堆是哪个分类的"。用 clip 保证色带两端跟着圆角走。
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun SoftCard(
|
fun SoftCard(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
padding: Dp = 14.dp,
|
padding: Dp = 14.dp,
|
||||||
|
accent: Color? = null,
|
||||||
onClick: (() -> Unit)? = null,
|
onClick: (() -> Unit)? = null,
|
||||||
content: @Composable ColumnScope.() -> Unit,
|
content: @Composable ColumnScope.() -> Unit,
|
||||||
) {
|
) {
|
||||||
|
val shape = RoundedCornerShape(14.dp)
|
||||||
val base = modifier
|
val base = modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.border(1.dp, Line, RoundedCornerShape(14.dp))
|
.clip(shape)
|
||||||
.background(Color.White, RoundedCornerShape(14.dp))
|
.border(1.dp, Line, shape)
|
||||||
|
.background(Color.White, shape)
|
||||||
Surface(
|
Surface(
|
||||||
modifier = if (onClick != null) base.clickable { onClick() } else base,
|
modifier = if (onClick != null) base.clickable { onClick() } else base,
|
||||||
color = Color.White,
|
color = Color.White,
|
||||||
shape = RoundedCornerShape(14.dp),
|
shape = shape,
|
||||||
) {
|
) {
|
||||||
|
Column {
|
||||||
|
if (accent != null) {
|
||||||
|
Box(Modifier.fillMaxWidth().height(3.dp).background(accent))
|
||||||
|
}
|
||||||
Column(modifier = Modifier.padding(padding), content = content)
|
Column(modifier = Modifier.padding(padding), content = content)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 分类色的小圆点:侧栏、筛选 chip、卡片 meta 里到处都用它。 */
|
||||||
|
@Composable
|
||||||
|
fun CategoryDot(color: Color, size: Dp = 8.dp, modifier: Modifier = Modifier) {
|
||||||
|
Box(modifier.size(size).background(color, CircleShape))
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SectionLabel(text: String, modifier: Modifier = Modifier) {
|
fun SectionLabel(text: String, modifier: Modifier = Modifier) {
|
||||||
|
|||||||
Reference in New Issue
Block a user