- 生产已关短信登录,登录页先读 /auth/methods 再决定入口:默认手机号/邮箱+密码, 后端开着短信时才出现验证码登录;邮箱账号 phone 为空,User.phone 改可空 - 新增邮箱验证码注册(一步建号即登录态) - 知识:分类色点/选中描边/加载态,管理面板可改名、选色(10 色板或手填 hex)、 建子分类、删除(知识回到未分类),卡片上边线用分类色 - 正文 16sp / 行高 1.7,列表标题 SemiBold,与 Web 阅读排版对齐 - README 更新登录方式与结构说明
This commit is contained in:
@@ -44,10 +44,34 @@ private data class Envelope<T>(
|
||||
@Serializable
|
||||
data class User(
|
||||
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 = "",
|
||||
)
|
||||
|
||||
/**
|
||||
* 这台服务现在开放哪些登录入口(后端 `/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
|
||||
data class Tokens(
|
||||
@SerialName("access_token") val accessToken: String,
|
||||
@@ -256,9 +280,12 @@ data class AppRelease(
|
||||
|
||||
/** 知识分类:工作区级的三级小树,fullPath 形如「研发 / 部署 / 回滚」。 */
|
||||
@Serializable
|
||||
data class KnowledgeCategory( val id: String,
|
||||
data class KnowledgeCategory(
|
||||
val id: String,
|
||||
val name: String = "",
|
||||
@SerialName("parent_id") val parentId: String? = null,
|
||||
/** 分类色(#RRGGBB),卡片上边线用它;没设时前端按 id 稳定挑一个默认色。 */
|
||||
val color: String? = null,
|
||||
val depth: Int = 1,
|
||||
@SerialName("sort_order") val sortOrder: Int = 0,
|
||||
val version: Int = 1,
|
||||
@@ -324,6 +351,25 @@ private data class SmsRequest(val phone: String)
|
||||
@Serializable
|
||||
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
|
||||
private data class RefreshRequest(@SerialName("refresh_token") val refreshToken: String)
|
||||
|
||||
@@ -370,6 +416,27 @@ private data class KnowledgeCreateRequest(
|
||||
@Serializable
|
||||
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
|
||||
private data class TicketCreateRequest(
|
||||
val title: String,
|
||||
@@ -475,6 +542,58 @@ class Api(private val tokens: TokenStore) {
|
||||
|
||||
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": [...]}` 而不是裸数组
|
||||
* (`backend/app/api/workspaces.py` 的 list_workspaces),
|
||||
@@ -736,6 +855,50 @@ class Api(private val tokens: TokenStore) {
|
||||
suspend fun knowledgeCategories(workspaceId: String): KnowledgeCategoryPage =
|
||||
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(
|
||||
workspaceId: String,
|
||||
|
||||
@@ -5,10 +5,13 @@ import android.net.Uri
|
||||
import android.provider.OpenableColumns
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
@@ -18,15 +21,19 @@ 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.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TextFieldDefaults
|
||||
@@ -39,6 +46,8 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
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.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
@@ -87,6 +96,76 @@ private fun parseTags(raw: String): List<String> =
|
||||
private fun catLabel(cat: KnowledgeCategory): String =
|
||||
"·".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
|
||||
fun KnowledgeScreen(
|
||||
api: Api,
|
||||
@@ -106,6 +185,7 @@ fun KnowledgeScreen(
|
||||
var adding by remember { mutableStateOf(false) }
|
||||
// null = 全部;"none" = 未分类;其它是分类 id(服务端含子分类)
|
||||
var catFilter by remember { mutableStateOf<String?>(null) }
|
||||
var managing by remember { mutableStateOf(false) }
|
||||
|
||||
suspend fun loadCategories() {
|
||||
runCatching { api.knowledgeCategories(workspace.id) }
|
||||
@@ -176,27 +256,26 @@ fun KnowledgeScreen(
|
||||
Row(
|
||||
modifier = Modifier.horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
SurfaceChip(
|
||||
text = "全部分类",
|
||||
selected = catFilter == null,
|
||||
onClick = { catFilter = null },
|
||||
)
|
||||
SurfaceChip(
|
||||
text = "未分类",
|
||||
selected = catFilter == "none",
|
||||
onClick = { catFilter = "none" },
|
||||
)
|
||||
CategoryChip("全部分类", null, catFilter == null) { catFilter = null }
|
||||
CategoryChip("未分类", null, catFilter == "none") { catFilter = "none" }
|
||||
categories.forEach { cat ->
|
||||
SurfaceChip(
|
||||
text = catLabel(cat),
|
||||
selected = catFilter == cat.id,
|
||||
onClick = { catFilter = cat.id },
|
||||
)
|
||||
CategoryChip(catLabel(cat), categoryColorOf(cat), catFilter == cat.id) {
|
||||
catFilter = cat.id
|
||||
}
|
||||
}
|
||||
if (loading) {
|
||||
Spacer(Modifier.width(4.dp))
|
||||
LoadingInline()
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(10.dp))
|
||||
GhostButton("记一条", onClick = { adding = true })
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
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 ->
|
||||
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) {
|
||||
SoftPill(docTypeLabel(doc.knowledge.docType))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
@@ -235,16 +319,27 @@ fun KnowledgeScreen(
|
||||
Text(
|
||||
doc.title,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
val meta = buildList {
|
||||
doc.category?.let { add(it.fullPath.ifBlank { it.name }) }
|
||||
doc.tags.forEach { add("#${it.name}") }
|
||||
// 分类用同色圆点带出来:卡片上边线看颜色,这里看名字,两处一致
|
||||
doc.category?.let { cat ->
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
categoryColorOf(cat)?.let {
|
||||
CategoryDot(it, 6.dp)
|
||||
Spacer(Modifier.width(5.dp))
|
||||
}
|
||||
Text(
|
||||
cat.fullPath.ifBlank { cat.name },
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = InkSoft,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
}
|
||||
if (meta.isNotEmpty()) {
|
||||
if (doc.tags.isNotEmpty()) {
|
||||
Text(
|
||||
meta.joinToString(" "),
|
||||
doc.tags.joinToString(" ") { "#${it.name}" },
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = Primary,
|
||||
)
|
||||
@@ -307,6 +402,373 @@ fun KnowledgeScreen(
|
||||
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 ->
|
||||
android.widget.TextView(ctx).apply {
|
||||
setTextIsSelectable(true)
|
||||
textSize = 14f
|
||||
setLineSpacing(0f, 1.5f)
|
||||
// 与 Web 阅读页对齐:正文 16sp、行高 1.7 —— 手机上读长文,14sp 太挤
|
||||
textSize = 16f
|
||||
setLineSpacing(0f, 1.7f)
|
||||
setTextColor(android.graphics.Color.parseColor("#1E293B"))
|
||||
markwon.setMarkdown(this, text)
|
||||
}
|
||||
@@ -574,7 +1037,8 @@ private fun KnowledgeDetailDialog(
|
||||
},
|
||||
text = {
|
||||
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)
|
||||
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.Row
|
||||
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.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
@@ -19,6 +20,7 @@ import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TextFieldDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@@ -32,23 +34,45 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
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.ApiException
|
||||
import com.dadao.task.data.AuthMethods
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
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
|
||||
fun LoginScreen(api: Api, onLoggedIn: () -> Unit) {
|
||||
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 code 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) }
|
||||
var registerOpen by remember { mutableStateOf(false) }
|
||||
|
||||
// 后端地址可改:包里默认是模拟器地址 10.0.2.2,真机必须换成电脑的局域网地址。
|
||||
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) {
|
||||
if (seconds > 0) {
|
||||
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() {
|
||||
scope.launch {
|
||||
busy = true
|
||||
@@ -102,16 +154,12 @@ fun LoginScreen(api: Api, onLoggedIn: () -> Unit) {
|
||||
devCode = data?.jsonObject?.get("dev_code")?.jsonPrimitive?.content
|
||||
error = null
|
||||
}
|
||||
.onFailure {
|
||||
val c = (it as? ApiException)?.code ?: 0
|
||||
if (c == 1) serverOpen = true
|
||||
error = verifyStatusCode(c).ifBlank { it.message ?: "验证码没发出去" }
|
||||
}
|
||||
.onFailure { report(it, "验证码没发出去") }
|
||||
busy = false
|
||||
}
|
||||
}
|
||||
|
||||
fun submit() {
|
||||
fun submitCode() {
|
||||
scope.launch {
|
||||
busy = true
|
||||
runCatching { api.login(phone.trim(), code.trim()) }
|
||||
@@ -119,15 +167,14 @@ fun LoginScreen(api: Api, onLoggedIn: () -> Unit) {
|
||||
error = null
|
||||
onLoggedIn()
|
||||
}
|
||||
.onFailure {
|
||||
val c = (it as? ApiException)?.code ?: 0
|
||||
if (c == 1) serverOpen = true
|
||||
error = verifyStatusCode(c).ifBlank { it.message ?: "登录没成功" }
|
||||
}
|
||||
.onFailure { report(it, "登录没成功") }
|
||||
busy = false
|
||||
}
|
||||
}
|
||||
|
||||
val smsAvailable = methods?.smsCode == true
|
||||
val registerAvailable = methods?.emailRegister == true
|
||||
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
@@ -160,39 +207,45 @@ fun LoginScreen(api: Api, onLoggedIn: () -> Unit) {
|
||||
Text("登录", style = MaterialTheme.typography.headlineMedium)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(
|
||||
"手机号 + 验证码,不用记密码。第一次登录会自动建好账号。",
|
||||
if (smsAvailable) {
|
||||
"手机号或邮箱 + 密码,也可以收验证码登录。"
|
||||
} else {
|
||||
"手机号或邮箱 + 密码。第一次来先注册一个账号。"
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = InkMuted,
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(22.dp))
|
||||
OutlinedTextField(
|
||||
value = phone,
|
||||
onValueChange = { phone = it.filter { c -> c.isDigit() }.take(11) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("手机号") },
|
||||
placeholder = { Text("11 位手机号", color = InkMuted) },
|
||||
singleLine = true,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
|
||||
colors = TextFieldDefaults.colors(
|
||||
focusedContainerColor = Color.White,
|
||||
unfocusedContainerColor = Color.White,
|
||||
focusedIndicatorColor = Primary,
|
||||
unfocusedIndicatorColor = Line,
|
||||
),
|
||||
)
|
||||
// 两条路都可以走时才给切换 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(12.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Spacer(Modifier.height(18.dp))
|
||||
if (mode == "password") {
|
||||
OutlinedTextField(
|
||||
value = code,
|
||||
onValueChange = { code = it.filter { c -> c.isDigit() }.take(6) },
|
||||
modifier = Modifier.weight(1f),
|
||||
label = { Text("验证码") },
|
||||
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 = KeyboardType.NumberPassword),
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = if (looksLikeEmail(account)) KeyboardType.Email else KeyboardType.Text,
|
||||
),
|
||||
colors = TextFieldDefaults.colors(
|
||||
focusedContainerColor = Color.White,
|
||||
unfocusedContainerColor = Color.White,
|
||||
@@ -200,23 +253,76 @@ fun LoginScreen(api: Api, onLoggedIn: () -> Unit) {
|
||||
unfocusedIndicatorColor = Line,
|
||||
),
|
||||
)
|
||||
Spacer(Modifier.width(10.dp))
|
||||
GhostButton(
|
||||
text = if (seconds > 0) "${seconds}s" else "获取验证码",
|
||||
enabled = phone.length == 11 && seconds == 0 && !busy,
|
||||
onClick = { send() },
|
||||
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(
|
||||
value = phone,
|
||||
onValueChange = { phone = it.filter { c -> c.isDigit() }.take(11) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("手机号") },
|
||||
placeholder = { Text("11 位手机号", color = InkMuted) },
|
||||
singleLine = true,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
|
||||
colors = TextFieldDefaults.colors(
|
||||
focusedContainerColor = Color.White,
|
||||
unfocusedContainerColor = Color.White,
|
||||
focusedIndicatorColor = Primary,
|
||||
unfocusedIndicatorColor = Line,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
val hint = devCode
|
||||
if (hint != null) {
|
||||
Spacer(Modifier.height(10.dp))
|
||||
SoftPill(
|
||||
"开发环境验证码:$hint",
|
||||
Primary,
|
||||
PrimarySoft,
|
||||
Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(Modifier.height(12.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(12.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 = phone.length == 11 && seconds == 0 && !busy,
|
||||
onClick = { send() },
|
||||
)
|
||||
}
|
||||
|
||||
val hint = devCode
|
||||
if (hint != null) {
|
||||
Spacer(Modifier.height(10.dp))
|
||||
SoftPill(
|
||||
"开发环境验证码:$hint",
|
||||
Primary,
|
||||
PrimarySoft,
|
||||
Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val notice = error
|
||||
@@ -226,12 +332,36 @@ fun LoginScreen(api: Api, onLoggedIn: () -> Unit) {
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(22.dp))
|
||||
PrimaryButton(
|
||||
text = if (busy) "请稍候…" else "登录",
|
||||
enabled = phone.length == 11 && code.length >= 4 && !busy,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
onClick = { submit() },
|
||||
)
|
||||
if (mode == "password") {
|
||||
PrimaryButton(
|
||||
text = if (busy) "请稍候…" else "登录",
|
||||
enabled = accountReady(account) && password.isNotEmpty() && !busy,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
onClick = { submitPassword() },
|
||||
)
|
||||
} else {
|
||||
PrimaryButton(
|
||||
text = if (busy) "请稍候…" else "登录",
|
||||
enabled = phone.length == 11 && code.length >= 4 && !busy,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
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))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
@@ -338,4 +468,190 @@ fun LoginScreen(api: Api, onLoggedIn: () -> Unit) {
|
||||
}
|
||||
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(
|
||||
item.title,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = if (item.isDone) InkMuted else Ink,
|
||||
textDecoration = if (item.isDone) TextDecoration.LineThrough else TextDecoration.None,
|
||||
)
|
||||
|
||||
@@ -164,7 +164,7 @@ fun TicketsScreen(
|
||||
Text(
|
||||
ticket.title,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Row(
|
||||
|
||||
@@ -27,6 +27,7 @@ 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.text.style.TextAlign
|
||||
@@ -96,27 +97,46 @@ fun SoftPill(text: String, fg: Color = InkSoft, bg: Color = PaperSunken, modifie
|
||||
}
|
||||
}
|
||||
|
||||
/** 卡片:白底、细描边、几乎不可见的投影。整个 App 用它承载所有内容。 */
|
||||
/**
|
||||
* 卡片:白底、细描边、几乎不可见的投影。整个 App 用它承载所有内容。
|
||||
*
|
||||
* [accent] 是卡片顶部的一条 3dp 色带——知识卡片用它显示所属分类的颜色,
|
||||
* 扫一眼列表就能按颜色找回"这堆是哪个分类的"。用 clip 保证色带两端跟着圆角走。
|
||||
*/
|
||||
@Composable
|
||||
fun SoftCard(
|
||||
modifier: Modifier = Modifier,
|
||||
padding: Dp = 14.dp,
|
||||
accent: Color? = null,
|
||||
onClick: (() -> Unit)? = null,
|
||||
content: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
val shape = RoundedCornerShape(14.dp)
|
||||
val base = modifier
|
||||
.fillMaxWidth()
|
||||
.border(1.dp, Line, RoundedCornerShape(14.dp))
|
||||
.background(Color.White, RoundedCornerShape(14.dp))
|
||||
.clip(shape)
|
||||
.border(1.dp, Line, shape)
|
||||
.background(Color.White, shape)
|
||||
Surface(
|
||||
modifier = if (onClick != null) base.clickable { onClick() } else base,
|
||||
color = Color.White,
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
shape = shape,
|
||||
) {
|
||||
Column(modifier = Modifier.padding(padding), content = content)
|
||||
Column {
|
||||
if (accent != null) {
|
||||
Box(Modifier.fillMaxWidth().height(3.dp).background(accent))
|
||||
}
|
||||
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
|
||||
fun SectionLabel(text: String, modifier: Modifier = Modifier) {
|
||||
Text(
|
||||
|
||||
Reference in New Issue
Block a user