diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index ac682c4..b7ff936 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -27,8 +27,8 @@ android { minSdk = 26 targetSdk = 35 // 版本号是 OTA 的唯一判据:后端只比对 version_code,改这里才算"有新版本" - versionCode = 4 - versionName = "0.4.0" + versionCode = 5 + versionName = "0.5.0" buildConfigField("String", "API_BASE", "\"$apiBase\"") } @@ -112,6 +112,10 @@ dependencies { // 表格与删除线:Web 端正文支持,安卓不能把 | 管道符原样吐给读者 implementation("io.noties.markwon:ext-tables:4.6.2") implementation("io.noties.markwon:ext-strikethrough:4.6.2") + // 正文图片:微信公众号转存后图片是正文里的 markdown 图,不能只当末尾附件 + implementation("io.noties.markwon:image:4.6.2") + // 底部导航图标用官方 Material 图标集,不自己画五个 ImageVector + implementation("androidx.compose.material:material-icons-core") implementation("io.coil-kt:coil-compose:2.7.0") debugImplementation("androidx.compose.ui:ui-tooling") } diff --git a/android/app/src/main/java/com/dadao/task/data/Api.kt b/android/app/src/main/java/com/dadao/task/data/Api.kt index 5239082..b5cb56e 100644 --- a/android/app/src/main/java/com/dadao/task/data/Api.kt +++ b/android/app/src/main/java/com/dadao/task/data/Api.kt @@ -490,6 +490,28 @@ class Api(private val tokens: TokenStore) { /** 当前生效的后端地址(不含 /api/v1),登录页展示与修改用。 */ fun currentOrigin(): String = origin + /** + * 把正文里的图片地址补成能直接加载的绝对地址。 + * + * 后端存正文时把远程图抓下来,改写成 `/api/v1/.../attachments/{id}/raw?exp=..&sig=..`, + * 签名 30 分钟就过期,所以加载时一律丢掉旧签名、换成当前 access token: + * 阅读长文不该出现"刚打开时图还在,翻到后面全裂了"。 + */ + fun mediaUrl(raw: String): String? { + val src = raw.trim().takeIf { it.isNotBlank() } ?: return null + val absolute = when { + src.startsWith("http://", ignoreCase = true) || + src.startsWith("https://", ignoreCase = true) -> src + src.startsWith("//") -> "https:$src" + src.startsWith("/") -> origin + src + else -> "$origin/$src" + } + if (!absolute.contains("/attachments/") || !absolute.contains("/raw")) return absolute + val token = tokens.accessToken?.takeIf { it.isNotBlank() } ?: return absolute + return absolute.substringBefore('?') + "?token=" + + java.net.URLEncoder.encode(token, "UTF-8") + } + /** 运行时切换后端地址;传 null 恢复编译期默认值。 */ fun setOrigin(value: String?) { tokens.apiBase = value?.trim()?.trimEnd('/')?.takeIf { it.isNotBlank() } @@ -787,9 +809,20 @@ class Api(private val tokens: TokenStore) { // ---- M2 工单 ---- - /** role: assigned(派给我的)/ requested(我发起的)/ all。 */ - suspend fun tickets(workspaceId: String, role: String = "assigned"): TicketPage = - call(path = "/workspaces/$workspaceId/tickets?role=$role", auth = true) + /** role: assigned(派给我的)/ requested(我发起的)/ all;query 搜标题与正文。 */ + suspend fun tickets( + workspaceId: String, + role: String = "assigned", + query: String? = null, + ): TicketPage { + val params = buildString { + append("?role=").append(role) + if (!query.isNullOrBlank()) { + append("&q=").append(java.net.URLEncoder.encode(query, "UTF-8")) + } + } + return call(path = "/workspaces/$workspaceId/tickets$params", auth = true) + } /** * 工单动作。状态机在服务端,这里只发"做什么",不自己算目标状态—— diff --git a/android/app/src/main/java/com/dadao/task/ui/AppFrame.kt b/android/app/src/main/java/com/dadao/task/ui/AppFrame.kt index d424c22..a46f197 100644 --- a/android/app/src/main/java/com/dadao/task/ui/AppFrame.kt +++ b/android/app/src/main/java/com/dadao/task/ui/AppFrame.kt @@ -17,6 +17,13 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Build +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.automirrored.filled.List +import androidx.compose.material.icons.filled.Notifications +import androidx.compose.material.icons.filled.Person +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -24,18 +31,19 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.dadao.task.data.Workspace /** 手机端的去处。看板不是独立入口,而是任务页顶部的一个切换(屏幕太窄,塞不下更多 tab)。 */ -enum class WorkspaceTab(val label: String) { - Tasks("任务"), - Tickets("工单"), - Knowledge("知识"), - Notifications("通知"), - Members("成员"), +enum class WorkspaceTab(val label: String, val icon: ImageVector) { + Tasks("任务", Icons.Filled.CheckCircle), + Tickets("工单", Icons.Filled.Build), + Knowledge("知识", Icons.AutoMirrored.Filled.List), + Notifications("通知", Icons.Filled.Notifications), + Members("成员", Icons.Filled.Person), } /** @@ -117,15 +125,26 @@ fun WorkspaceScaffold( Modifier .weight(1f) .clickable { if (!active) onTab(item) } - .padding(vertical = 6.dp), + .padding(vertical = 4.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { + // 图标是主要识别入口:22dp 的色条在阳光下几乎看不见,文字又只有两个字 Box( Modifier - .size(width = 22.dp, height = 3.dp) - .background(if (active) Primary else Color.Transparent, CircleShape), - ) - Spacer(Modifier.height(6.dp)) + .background( + if (active) PrimarySoft else Color.Transparent, + RoundedCornerShape(10.dp), + ) + .padding(horizontal = 12.dp, vertical = 3.dp), + ) { + Icon( + item.icon, + contentDescription = item.label, + tint = if (active) Primary else InkMuted, + modifier = Modifier.size(21.dp), + ) + } + Spacer(Modifier.height(3.dp)) Text( item.label, style = MaterialTheme.typography.labelMedium, diff --git a/android/app/src/main/java/com/dadao/task/ui/KnowledgeScreen.kt b/android/app/src/main/java/com/dadao/task/ui/KnowledgeScreen.kt index c54d752..b76bfe9 100644 --- a/android/app/src/main/java/com/dadao/task/ui/KnowledgeScreen.kt +++ b/android/app/src/main/java/com/dadao/task/ui/KnowledgeScreen.kt @@ -13,6 +13,7 @@ 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.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight @@ -20,8 +21,10 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items @@ -29,8 +32,12 @@ 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.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material3.AlertDialog import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Surface @@ -53,7 +60,6 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import com.dadao.task.data.Api @@ -62,11 +68,8 @@ import com.dadao.task.data.Attachment import com.dadao.task.data.KnowledgeCategory import com.dadao.task.data.KnowledgeDoc import com.dadao.task.data.Workspace -import io.noties.markwon.Markwon -import io.noties.markwon.ext.strikethrough.StrikethroughPlugin -import io.noties.markwon.ext.tables.TablePlugin -import io.noties.markwon.ext.tables.TableTheme import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -189,6 +192,9 @@ fun KnowledgeScreen( // null = 全部;"none" = 未分类;其它是分类 id(服务端含子分类) var catFilter by remember { mutableStateOf(null) } var managing by remember { mutableStateOf(false) } + var query by remember { mutableStateOf("") } + var appliedQuery by remember { mutableStateOf("") } + var refreshTick by remember { mutableStateOf(0) } suspend fun loadCategories() { runCatching { api.knowledgeCategories(workspace.id) } @@ -210,6 +216,7 @@ fun KnowledgeScreen( statuses, categoryId = if (filter != null && filter != "none") filter else null, uncategorized = filter == "none", + query = appliedQuery.ifBlank { null }, ) } .onSuccess { @@ -220,7 +227,14 @@ fun KnowledgeScreen( loading = false } - LaunchedEffect(tab, catFilter) { reload() } + LaunchedEffect(tab, catFilter, appliedQuery, refreshTick) { reload() } + // 搜索防抖:中文输入法上每敲一个拼音都会重组,350ms 内没有再动才真正发请求 + LaunchedEffect(query) { + val target = query.trim() + if (target == appliedQuery) return@LaunchedEffect + delay(350) + appliedQuery = target + } LaunchedEffect(Unit) { loadCategories() } // 从编辑器保存回来:本页是新建出来的,刷完列表再把那条详情重新打开。 @@ -258,6 +272,14 @@ fun KnowledgeScreen( modifier = Modifier.padding(top = 4.dp), ) Spacer(Modifier.height(10.dp)) + SearchRefreshBar( + value = query, + onValue = { query = it }, + placeholder = "搜标题、正文或摘要…", + refreshing = loading, + onRefresh = { refreshTick += 1 }, + ) + Spacer(Modifier.height(10.dp)) Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { knowledgeTabs.forEach { (key, label) -> SurfaceChip(text = label, selected = tab == key, onClick = { tab = key }) @@ -294,14 +316,13 @@ fun KnowledgeScreen( if (!loading && docs.isEmpty()) { item { EmptyState( - if (catFilter != null) { - "这个分类下还没有知识" - } else if (tab == "all") { - "还没有知识" - } else { - "这一类里还没有知识" + when { + appliedQuery.isNotBlank() -> "没有搜到「$appliedQuery」" + catFilter != null -> "这个分类下还没有知识" + tab == "all" -> "还没有知识" + else -> "这一类里还没有知识" }, - "把工单验收通过,或在任务详情里点「沉淀成知识」", + "换个词试试,或把工单验收通过、在任务详情里点「沉淀成知识」", ) } } @@ -365,7 +386,7 @@ fun KnowledgeScreen( val target = open if (target != null) { - KnowledgeDetailDialog( + KnowledgeReader( api = api, workspace = workspace, doc = target, @@ -778,56 +799,6 @@ private fun CategoryEditorCard( } } -/** - * 正文的「看」形态:Markwon 把 Markdown 排成排版好的正文。 - * - * 编辑走 RichTextEditorScreen,所以这里永远是只读——点开一条知识先看到内容本身, - * 而不是一堆 `##`、`-` 原样摆着。 - */ -@Composable -private fun KnowledgeBody(text: String) { - if (text.isBlank()) { - Text("(还没有正文,点「编辑正文」补齐)", fontSize = 13.sp, color = InkMuted) - return - } - val context = LocalContext.current - // 表格/删除线插件:Web 正文支持表格,安卓照抄同一份 markdown 时要能画出来, - // 否则 | 管道符会原样铺在正文里,像没解析。 - val markwon = remember { - // 表格主题按 Web 的纸面来:1dp 浅线、表头浅底、偶数行淡一档。 - // 用默认主题的话边框是 1px 纯黑,一格一格的,像 Excel 截图。 - val density = context.resources.displayMetrics.density - val tableTheme = TableTheme.Builder() - .tableBorderWidth((1 * density).toInt()) - .tableBorderColor(android.graphics.Color.parseColor("#E3E7EF")) - .tableCellPadding((8 * density).toInt()) - .tableHeaderRowBackgroundColor(android.graphics.Color.parseColor("#F4F5F9")) - .tableOddRowBackgroundColor(android.graphics.Color.WHITE) - .tableEvenRowBackgroundColor(android.graphics.Color.parseColor("#FAFAFC")) - .build() - Markwon.builder(context) - .usePlugin(TablePlugin.create(tableTheme)) - .usePlugin(StrikethroughPlugin.create()) - .build() - } - AndroidView( - factory = { ctx -> - android.widget.TextView(ctx).apply { - setTextIsSelectable(true) - // 与 Web 阅读页对齐:正文 16sp、行高 1.7 —— 手机上读长文,14sp 太挤 - textSize = 16f - setLineSpacing(0f, 1.7f) - setTextColor(android.graphics.Color.parseColor("#1E293B")) - markwon.setMarkdown(this, text) - } - }, - update = { view -> markwon.setMarkdown(view, text) }, - modifier = Modifier - .fillMaxWidth() - .heightIn(min = 80.dp), - ) -} - private fun sourceLine(doc: KnowledgeDoc): String { val src = doc.source return if (src != null && src.title.isNotBlank()) { @@ -985,8 +956,15 @@ private fun KnowledgeComposerDialog( } } +/** + * 知识阅读页:整屏,而不是弹窗。 + * + * 手机上弹窗上下各留一条黑边,正文被压进 560dp 高的小窗里翻几十屏。这里铺满屏幕: + * 顶部只留返回、标题和编辑,底部是分类/标签与状态动作,正文独占剩下的全部高度。 + * 附件上传、分类标签、状态流转都保留在这一页——读和改不该是两套界面。 + */ @Composable -private fun KnowledgeDetailDialog( +private fun KnowledgeReader( api: Api, workspace: Workspace, doc: KnowledgeDoc, @@ -999,10 +977,10 @@ private fun KnowledgeDetailDialog( ) { val scope = rememberCoroutineScope() val context = LocalContext.current - var action by remember { mutableStateOf(doc.status) } - var version by remember { mutableStateOf(doc.version) } - var categoryId by remember { mutableStateOf(doc.category?.id) } - var tags by remember { mutableStateOf(doc.tags.joinToString(" ") { it.name }) } + var action by remember(doc.id) { mutableStateOf(doc.status) } + var version by remember(doc.id) { mutableStateOf(doc.version) } + var categoryId by remember(doc.id) { mutableStateOf(doc.category?.id) } + var tags by remember(doc.id) { mutableStateOf(doc.tags.joinToString(" ") { it.name }) } var savingMeta by remember { mutableStateOf(false) } var metaNotice by remember { mutableStateOf(null) } /** @@ -1047,124 +1025,50 @@ private fun KnowledgeDetailDialog( else -> emptyList() } - AlertDialog( + // 正文已经内嵌的图片不再在末尾附件里重复一遍:转存公众号文章时, + // 一篇十张图的文章会先读完十张图,再看到十个意义不明的「xxxx.jpg 128 KB」。 + val inlineIds = remember(doc.bodyMd) { inlineAttachmentIds(doc.bodyMd) } + val fileAttachments = attachments.filterNot { it.id in inlineIds } + + Dialog( onDismissRequest = onClose, - title = { - Column { - Row(verticalAlignment = Alignment.CenterVertically) { - SoftPill(docTypeLabel(doc.knowledge.docType)) - Spacer(Modifier.width(6.dp)) - StatusPill(action) - } - Spacer(Modifier.height(8.dp)) - Text(doc.title, style = MaterialTheme.typography.titleMedium) - } - }, - text = { - Column( - // 阅读区给足高度:手机上一屏能看二十来行,不用一直往里滚 - Modifier.heightIn(min = 320.dp, max = 560.dp).verticalScroll(rememberScrollState()), - ) { - Text(sourceLine(doc), style = MaterialTheme.typography.labelSmall, color = InkMuted) - Spacer(Modifier.height(10.dp)) - // 点开就是「看」:正文用 Markwon 排好版再显示,要看原文/改内容点「编辑正文」 - KnowledgeBody(doc.bodyMd) - - // 附件:手册、日志、导出表格都能挂在这条知识下,点一下用浏览器打开 - Spacer(Modifier.height(14.dp)) - SectionLabel("附件") - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - if (attachments.isEmpty()) "还没有附件" else "${attachments.size} 个", - style = MaterialTheme.typography.labelSmall, - color = InkMuted, - ) - Spacer(Modifier.width(10.dp)) - TextButton( - enabled = !uploading, - onClick = { picker.launch(arrayOf("*/*")) }, - ) { Text(if (uploading) "上传中…" else "上传附件") } - } - attachments.forEach { row -> - Row( - Modifier.fillMaxWidth().padding(vertical = 2.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - row.filename, - style = MaterialTheme.typography.bodySmall, - color = Ink, - modifier = Modifier.weight(1f), - ) - Text( - humanSize(row.size), - style = MaterialTheme.typography.labelSmall, - color = InkMuted, - ) - Spacer(Modifier.width(4.dp)) - TextButton( - onClick = { - // raw_url 是带短时签名的相对路径,拼上当前后端地址才能在浏览器里打开 - val url = row.rawUrl?.let { api.currentOrigin() + it } ?: return@TextButton - runCatching { - context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) - }.onFailure { onFail(it) } - }, - ) { Text("打开") } + // 关键:默认 Dialog 宽度被限制在屏幕的 90% 左右,高度也会包住内容。 + // 这两项一起才真正拿到整块屏幕。 + properties = DialogProperties( + usePlatformDefaultWidth = false, + decorFitsSystemWindows = false, + ), + ) { + Surface(Modifier.fillMaxSize(), color = Paper) { + Column(Modifier.fillMaxSize().statusBarsPadding()) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 2.dp, end = 10.dp, top = 2.dp, bottom = 2.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton(onClick = onClose) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "返回", tint = Ink) } - } - - // 已发布的也能改(后端改完会重建检索切片);归档的先恢复成草稿再动,免得"收起来的"被悄悄改写 - if (action != "archived") { - Spacer(Modifier.height(14.dp)) - SectionLabel("分类") - Row( - modifier = Modifier.horizontalScroll(rememberScrollState()), - horizontalArrangement = Arrangement.spacedBy(6.dp), - ) { - SurfaceChip( - text = "未分类", - selected = categoryId == null, - onClick = { categoryId = null }, + Column(Modifier.weight(1f)) { + Text( + doc.title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 2, ) - categories.forEach { cat -> - SurfaceChip( - text = catLabel(cat), - selected = categoryId == cat.id, - onClick = { categoryId = cat.id }, - ) + Spacer(Modifier.height(3.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + SoftPill(docTypeLabel(doc.knowledge.docType)) + Spacer(Modifier.width(6.dp)) + StatusPill(action) + if (doc.knowledge.generatedBy == "rule") { + Spacer(Modifier.width(6.dp)) + SoftPill("自动整理", Clay, ClaySoft) + } } } - Spacer(Modifier.height(10.dp)) - OutlinedTextField( - value = tags, - onValueChange = { tags = it }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - placeholder = { Text("标签,空格或逗号分隔", color = InkMuted) }, - shape = RoundedCornerShape(10.dp), - colors = TextFieldDefaults.colors( - focusedContainerColor = Color.White, - unfocusedContainerColor = Color.White, - focusedIndicatorColor = Primary, - unfocusedIndicatorColor = Line, - ), - ) - if (metaNotice != null) { - Text( - metaNotice.orEmpty(), - style = MaterialTheme.typography.labelSmall, - color = Primary, - modifier = Modifier.padding(top = 6.dp), - ) - } - } - } - }, - confirmButton = { - Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { - TextButton( - onClick = { + TextButton(onClick = { onEdit( EditRequest( itemId = doc.id, @@ -1176,58 +1080,181 @@ private fun KnowledgeDetailDialog( ), ) onClose() - }, - ) { Text("编辑正文") } - if (action != "archived") { - TextButton( - enabled = !savingMeta, - onClick = { - scope.launch { - savingMeta = true - runCatching { - api.updateKnowledge( - workspace.id, - doc.id, - version, - tags = parseTags(tags), - categoryId = categoryId, - clearCategory = categoryId == null, + }) { Text("编辑") } + } + DividerLine() + + LazyColumn( + modifier = Modifier.weight(1f).fillMaxWidth(), + contentPadding = PaddingValues(start = 20.dp, end = 20.dp, top = 16.dp, bottom = 24.dp), + ) { + item { + Text(sourceLine(doc), style = MaterialTheme.typography.labelSmall, color = InkMuted) + } + item { + Spacer(Modifier.height(12.dp)) + MarkdownBody(doc.bodyMd, api) + } + + // 附件:手册、日志、导出表格都能挂在这条知识下,点一下用浏览器打开 + item { + Spacer(Modifier.height(20.dp)) + SectionLabel("附件") + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + when { + fileAttachments.isNotEmpty() -> "${fileAttachments.size} 个" + inlineIds.isNotEmpty() -> "图片已内嵌在正文中" + else -> "还没有附件" + }, + style = MaterialTheme.typography.labelSmall, + color = InkMuted, + ) + Spacer(Modifier.width(10.dp)) + TextButton( + enabled = !uploading, + onClick = { picker.launch(arrayOf("*/*")) }, + ) { Text(if (uploading) "上传中…" else "上传附件") } + } + fileAttachments.forEach { row -> + Row( + Modifier.fillMaxWidth().padding(vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + row.filename, + style = MaterialTheme.typography.bodySmall, + color = Ink, + modifier = Modifier.weight(1f), + ) + Text( + humanSize(row.size), + style = MaterialTheme.typography.labelSmall, + color = InkMuted, + ) + Spacer(Modifier.width(4.dp)) + TextButton( + onClick = { + // raw_url 是带短时签名的相对路径,拼上当前后端地址才能在浏览器里打开 + val url = row.rawUrl?.let { api.currentOrigin() + it } + ?: return@TextButton + runCatching { + context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) + }.onFailure { onFail(it) } + }, + ) { Text("打开") } + } + } + } + + // 已发布的也能改(后端改完会重建检索切片);归档的先恢复成草稿再动,免得"收起来的"被悄悄改写 + if (action != "archived") { + item { + Spacer(Modifier.height(20.dp)) + SectionLabel("分类") + Row( + modifier = Modifier.horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + SurfaceChip( + text = "未分类", + selected = categoryId == null, + onClick = { categoryId = null }, + ) + categories.forEach { cat -> + SurfaceChip( + text = catLabel(cat), + selected = categoryId == cat.id, + onClick = { categoryId = cat.id }, ) } + } + Spacer(Modifier.height(10.dp)) + OutlinedTextField( + value = tags, + onValueChange = { tags = it }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + placeholder = { Text("标签,空格或逗号分隔", color = InkMuted) }, + shape = RoundedCornerShape(10.dp), + colors = TextFieldDefaults.colors( + focusedContainerColor = Color.White, + unfocusedContainerColor = Color.White, + focusedIndicatorColor = Primary, + unfocusedIndicatorColor = Line, + ), + ) + if (metaNotice != null) { + Text( + metaNotice.orEmpty(), + style = MaterialTheme.typography.labelSmall, + color = Primary, + modifier = Modifier.padding(top = 6.dp), + ) + } + } + } + } + + DividerLine() + Row( + modifier = Modifier + .fillMaxWidth() + .navigationBarsPadding() + .horizontalScroll(rememberScrollState()) + .padding(horizontal = 8.dp, vertical = 2.dp), + horizontalArrangement = Arrangement.spacedBy(2.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (action != "archived") { + TextButton( + enabled = !savingMeta, + onClick = { + scope.launch { + savingMeta = true + runCatching { + api.updateKnowledge( + workspace.id, + doc.id, + version, + tags = parseTags(tags), + categoryId = categoryId, + clearCategory = categoryId == null, + ) + } + .onSuccess { + version = it.version + metaNotice = "分类与标签已保存" + onChanged() + onCategoriesChanged() + } + .onFailure { onFail(it) } + savingMeta = false + } + }, + ) { Text("保存分类标签") } + } + actions.forEach { (key, label) -> + TextButton(onClick = { + scope.launch { + runCatching { api.knowledgeAction(workspace.id, doc.id, key) } .onSuccess { - version = it.version - metaNotice = "分类与标签已保存" + action = when (key) { + "submit_review" -> "in_review" + "withdraw", "restore" -> "draft" + "publish" -> "published" + else -> "archived" + } onChanged() - onCategoriesChanged() } .onFailure { onFail(it) } - savingMeta = false } - }, - ) { Text("保存分类标签") } - } - actions.forEach { (key, label) -> - TextButton(onClick = { - scope.launch { - runCatching { api.knowledgeAction(workspace.id, doc.id, key) } - .onSuccess { - action = when (key) { - "submit_review" -> "in_review" - "withdraw", "restore" -> "draft" - "publish" -> "published" - else -> "archived" - } - onChanged() - } - .onFailure { onFail(it) } - } - }) { Text(label) } + }) { Text(label) } + } } } - }, - dismissButton = { TextButton(onClick = onClose) { Text("关闭") } }, - containerColor = Paper, - ) + } + } } /** 附件列表里显示的大小:人与手机都不需要精确到字节。 */ diff --git a/android/app/src/main/java/com/dadao/task/ui/Markdown.kt b/android/app/src/main/java/com/dadao/task/ui/Markdown.kt new file mode 100644 index 0000000..0e27642 --- /dev/null +++ b/android/app/src/main/java/com/dadao/task/ui/Markdown.kt @@ -0,0 +1,200 @@ +package com.dadao.task.ui + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.drawable.BitmapDrawable +import android.net.Uri +import android.util.LruCache +import android.widget.TextView +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.viewinterop.AndroidView +import com.dadao.task.data.Api +import io.noties.markwon.Markwon +import io.noties.markwon.ext.strikethrough.StrikethroughPlugin +import io.noties.markwon.ext.tables.TablePlugin +import io.noties.markwon.ext.tables.TableTheme +import io.noties.markwon.image.ImageItem +import io.noties.markwon.image.ImagesPlugin +import io.noties.markwon.image.SchemeHandler +import okhttp3.OkHttpClient +import okhttp3.Request +import java.io.IOException +import java.util.concurrent.TimeUnit + +/** 正文里引用过的附件 id:这些图已经内嵌在文中,不该再在末尾附件列表里重复一遍。 */ +private val attachmentRefRe = + Regex("""/attachments/([0-9a-fA-F-]{36})/raw""") + +fun inlineAttachmentIds(markdown: String): Set = + attachmentRefRe.findAll(markdown).map { it.groupValues[1] }.toSet() + +/** 正文图片解码后的缓存:同一条知识反复进出阅读页时不必重新下载同一个文件。 */ +private val imageCache = object : LruCache(32 * 1024) { + override fun sizeOf(key: String, value: Bitmap): Int = value.byteCount / 1024 +} + +private const val MAX_IMAGE_BYTES = 12L * 1024 * 1024 + +/** + * 正文图片地址全是相对路径(`/api/v1/.../attachments/../raw?...`), + * 而 Markwon 只把带 http/https scheme 的地址交给自定义 handler,相对路径会被 + * 默认 handler 当成未知协议直接丢掉。所以渲染前先把地址补成绝对地址。 + */ +private val markdownImageRe = Regex("""(!\[[^\]]*\]\()([^)\s]+)([^)]*\))""") +private val htmlImageRe = Regex("""(]*\bsrc=["'])([^"']+)(["'])""", RegexOption.IGNORE_CASE) + +private fun rewriteImageUrls(markdown: String, api: Api): String { + val md = markdownImageRe.replace(markdown) { m -> + val absolute = api.mediaUrl(m.groupValues[2]) ?: m.groupValues[2] + m.groupValues[1] + absolute + m.groupValues[3] + } + return htmlImageRe.replace(md) { m -> + val absolute = api.mediaUrl(m.groupValues[2]) ?: m.groupValues[2] + m.groupValues[1] + absolute + m.groupValues[3] + } +} + +/** + * 正文图片加载:后端把文章里的远程图抓成了附件,正文只留相对地址。 + * + * 两条关键处理: + * 1. 相对地址补上当前后端域名,附件链接再换成 `?token=`——签名 30 分钟过期, + * 长文读到一半图全裂开比"没图"更难受。 + * 2. 按屏幕宽度重新采样,微信公众号的 677px 原图在手机上刚好占满一屏, + * 超大截图也不会把正文顶成一条缝。 + * + * 走 SchemeHandler 而不是自定义 AsyncDrawableLoader:Markwon 的图片管线会在 + * 后台线程调这里,我们一次性把「下载 + 解码 + 缩放」做完,直接交回 Drawable。 + */ +private class TokenSchemeHandler( + private val api: Api, + private val context: Context, +) : SchemeHandler() { + + private val client = OkHttpClient.Builder() + .connectTimeout(10, TimeUnit.SECONDS) + .readTimeout(20, TimeUnit.SECONDS) + .build() + + override fun supportedSchemes(): Collection = listOf("http", "https") + + override fun handle(destination: String, uri: Uri): ImageItem { + val url = api.mediaUrl(destination) + ?: throw IOException("图片地址为空") + imageCache.get(url)?.let { + return ImageItem.withResult(BitmapDrawable(context.resources, it)) + } + client.newCall(Request.Builder().url(url).build()).execute().use { response -> + if (!response.isSuccessful) throw IOException("图片加载失败 HTTP ${response.code}") + val body = response.body ?: throw IOException("图片响应为空") + if (body.contentLength() > MAX_IMAGE_BYTES) throw IOException("图片超过 12MB") + val bytes = body.bytes() + if (bytes.size > MAX_IMAGE_BYTES) throw IOException("图片超过 12MB") + + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds) + if (bounds.outWidth <= 0 || bounds.outHeight <= 0) throw IOException("图片解码失败") + + val target = targetWidthPx(context) + var sample = 1 + while (bounds.outWidth / (sample * 2) >= target) sample *= 2 + val decoded = BitmapFactory.decodeByteArray( + bytes, + 0, + bytes.size, + BitmapFactory.Options().apply { inSampleSize = sample }, + ) ?: throw IOException("图片解码失败") + + val bitmap = if (decoded.width > target) { + Bitmap.createScaledBitmap( + decoded, + target, + (decoded.height * target / decoded.width).coerceAtLeast(1), + true, + ) + } else { + decoded + } + // 解码出来的图没有密度信息,BitmapDrawable 会按 160dpi 基准再放大一遍; + // 标成屏幕密度,drawable 的宽度才等于我们算好的目标像素 + bitmap.density = context.resources.displayMetrics.densityDpi + imageCache.put(url, bitmap) + return ImageItem.withResult(BitmapDrawable(context.resources, bitmap)) + } + } +} + +private fun targetWidthPx(context: Context): Int { + val metrics = context.resources.displayMetrics + return (metrics.widthPixels - 40 * metrics.density).toInt().coerceIn(320, 1600) +} + +/** + * 正文的「看」形态:Markwon 把 Markdown 排成排版好的正文。 + * + * 编辑走 RichTextEditorScreen,所以这里永远是只读——点开一条知识先看到内容本身, + * 而不是一堆 `##`、`-` 原样摆着。图片、表格、链接都在这层解析。 + */ +@Composable +fun MarkdownBody(text: String, api: Api) { + if (text.isBlank()) { + androidx.compose.material3.Text( + "(还没有正文,点「编辑」补齐)", + fontSize = 13.sp, + color = InkMuted, + ) + return + } + val context = LocalContext.current + // 图片地址先补成绝对地址:Markwon 的相对路径不会进自定义 scheme handler + val body = remember(text, api) { rewriteImageUrls(text, api) } + val markwon = remember(api) { + // 表格主题按 Web 的纸面来:1dp 浅线、表头浅底、偶数行淡一档。 + // 用默认主题的话边框是 1px 纯黑,一格一格的,像 Excel 截图。 + val density = context.resources.displayMetrics.density + val tableTheme = TableTheme.Builder() + .tableBorderWidth((1 * density).toInt()) + .tableBorderColor(android.graphics.Color.parseColor("#E3E7EF")) + .tableCellPadding((8 * density).toInt()) + .tableHeaderRowBackgroundColor(android.graphics.Color.parseColor("#F4F5F9")) + .tableOddRowBackgroundColor(android.graphics.Color.WHITE) + .tableEvenRowBackgroundColor(android.graphics.Color.parseColor("#FAFAFC")) + .build() + Markwon.builder(context) + .usePlugin(TablePlugin.create(tableTheme)) + .usePlugin(StrikethroughPlugin.create()) + .usePlugin( + ImagesPlugin.create { plugin -> + // http/https 交给自己的 handler:拼域名、换 token、限宽 + plugin.addSchemeHandler(TokenSchemeHandler(api, context)) + }, + ) + .build() + } + AndroidView( + factory = { ctx -> + TextView(ctx).apply { + // 阅读页正文 16sp、行高 1.7 —— 手机上读长文,14sp 太挤 + textSize = 16f + setLineSpacing(0f, 1.7f) + setTextColor(android.graphics.Color.parseColor("#1E293B")) + // 不 setTextIsSelectable:可选中会吃掉链接点击,文章里的原文链接就点不动了 + linksClickable = true + movementMethod = android.text.method.LinkMovementMethod.getInstance() + markwon.setMarkdown(this, body) + } + }, + update = { view -> markwon.setMarkdown(view, body) }, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 80.dp), + ) +} diff --git a/android/app/src/main/java/com/dadao/task/ui/TaskListScreen.kt b/android/app/src/main/java/com/dadao/task/ui/TaskListScreen.kt index 64612a9..6fb5cc1 100644 --- a/android/app/src/main/java/com/dadao/task/ui/TaskListScreen.kt +++ b/android/app/src/main/java/com/dadao/task/ui/TaskListScreen.kt @@ -40,6 +40,7 @@ import com.dadao.task.data.Api import com.dadao.task.data.ApiException import com.dadao.task.data.Item import com.dadao.task.data.Workspace +import kotlinx.coroutines.delay import kotlinx.coroutines.launch // 「全部」放第一个,与 Web 任务页保持一致;默认选中的视图在下面单独定,不靠顺序。 @@ -67,13 +68,16 @@ fun TaskListScreen( var loading by remember { mutableStateOf(true) } var draft by remember { mutableStateOf("") } var query by remember { mutableStateOf("") } + // 真正发给服务端的搜索词:等用户停手 350ms 再发,否则每敲一个字打一次接口 + var appliedQuery by remember { mutableStateOf("") } + var refreshTick by remember { mutableStateOf(0) } var openId by remember { mutableStateOf(null) } var detailRefresh by remember { mutableStateOf(0) } var error by remember { mutableStateOf(null) } suspend fun reload() { loading = true - runCatching { api.items(workspace.id, view = view, query = query.ifBlank { null }) } + runCatching { api.items(workspace.id, view = view, query = appliedQuery.ifBlank { null }) } .onSuccess { items = it.items error = null @@ -82,7 +86,14 @@ fun TaskListScreen( loading = false } - LaunchedEffect(view, query) { reload() } + LaunchedEffect(view, appliedQuery, refreshTick) { reload() } + + LaunchedEffect(query) { + val target = query.trim() + if (target == appliedQuery) return@LaunchedEffect + delay(350) + appliedQuery = target + } // 从编辑器保存回来:本页是新建出来的,先刷数据再把那一条详情重新打开。 LaunchedEffect(reopenItem) { @@ -136,25 +147,12 @@ fun TaskListScreen( } item { - OutlinedTextField( + SearchRefreshBar( value = query, - onValueChange = { query = it }, - modifier = Modifier.fillMaxWidth(), - placeholder = { - Text( - "搜标题或正文…", - color = InkMuted, - style = MaterialTheme.typography.bodySmall, - ) - }, - singleLine = true, - shape = RoundedCornerShape(10.dp), - colors = TextFieldDefaults.colors( - focusedContainerColor = Color.White, - unfocusedContainerColor = Color.White, - focusedIndicatorColor = Primary, - unfocusedIndicatorColor = Line, - ), + onValue = { query = it }, + placeholder = "搜标题或正文…", + refreshing = loading, + onRefresh = { refreshTick += 1 }, ) } diff --git a/android/app/src/main/java/com/dadao/task/ui/TicketsScreen.kt b/android/app/src/main/java/com/dadao/task/ui/TicketsScreen.kt index 61daefc..7e1149e 100644 --- a/android/app/src/main/java/com/dadao/task/ui/TicketsScreen.kt +++ b/android/app/src/main/java/com/dadao/task/ui/TicketsScreen.kt @@ -36,6 +36,7 @@ import com.dadao.task.data.ApiException import com.dadao.task.data.Member import com.dadao.task.data.Ticket import com.dadao.task.data.Workspace +import kotlinx.coroutines.delay import kotlinx.coroutines.launch private val ticketRoles = listOf( @@ -53,6 +54,9 @@ fun TicketsScreen( ) { val scope = rememberCoroutineScope() var role by remember { mutableStateOf("assigned") } + var query by remember { mutableStateOf("") } + var appliedQuery by remember { mutableStateOf("") } + var refreshTick by remember { mutableStateOf(0) } var tickets by remember { mutableStateOf>(emptyList()) } var loading by remember { mutableStateOf(true) } var message by remember { mutableStateOf(null) } @@ -72,7 +76,7 @@ fun TicketsScreen( suspend fun reload() { loading = true - runCatching { api.tickets(workspace.id, role) } + runCatching { api.tickets(workspace.id, role, appliedQuery.ifBlank { null }) } .onSuccess { tickets = it.tickets message = null @@ -81,7 +85,14 @@ fun TicketsScreen( loading = false } - LaunchedEffect(role) { reload() } + LaunchedEffect(role, appliedQuery, refreshTick) { reload() } + + LaunchedEffect(query) { + val target = query.trim() + if (target == appliedQuery) return@LaunchedEffect + delay(350) + appliedQuery = target + } // 从通知点进来:切到"全部"才看得见那张单,然后清掉待处理标记 LaunchedEffect(focusId) { @@ -140,6 +151,14 @@ fun TicketsScreen( creating = true }) } + Spacer(Modifier.height(10.dp)) + SearchRefreshBar( + value = query, + onValue = { query = it }, + placeholder = "搜工单标题或正文…", + refreshing = loading, + onRefresh = { refreshTick += 1 }, + ) } } @@ -305,7 +324,10 @@ fun TicketsScreen( .onSuccess { creating = false message = null + // 刚开完的单一定在「我发起的」里;如果本来就停在这一栏, + // 单靠 role 变化不会触发 reload,补一次刷新 role = "requested" + refreshTick += 1 } .onFailure { fail(it) } } diff --git a/android/app/src/main/java/com/dadao/task/ui/Widgets.kt b/android/app/src/main/java/com/dadao/task/ui/Widgets.kt index 50b6b34..835dde6 100644 --- a/android/app/src/main/java/com/dadao/task/ui/Widgets.kt +++ b/android/app/src/main/java/com/dadao/task/ui/Widgets.kt @@ -18,12 +18,19 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Search import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -199,6 +206,75 @@ fun GhostButton( } } +/** + * 列表页统一的「搜索 + 刷新」条:任务、工单、知识都用它,免得三处各长一个样。 + * 刷新按钮独立成型而不是下拉手势——列表里到处是卡片,下拉容易变成滚动。 + */ +@Composable +fun SearchRefreshBar( + value: String, + onValue: (String) -> Unit, + placeholder: String, + refreshing: Boolean, + onRefresh: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + OutlinedTextField( + value = value, + onValueChange = onValue, + modifier = Modifier.weight(1f), + placeholder = { + Text(placeholder, style = MaterialTheme.typography.bodySmall, color = InkMuted) + }, + leadingIcon = { + Icon( + Icons.Filled.Search, + contentDescription = null, + tint = InkMuted, + modifier = Modifier.size(18.dp), + ) + }, + singleLine = true, + shape = RoundedCornerShape(10.dp), + colors = TextFieldDefaults.colors( + focusedContainerColor = Color.White, + unfocusedContainerColor = Color.White, + focusedIndicatorColor = Primary, + unfocusedIndicatorColor = Line, + ), + ) + Box( + modifier = Modifier + .size(48.dp) + .clip(RoundedCornerShape(12.dp)) + .background(Color.White) + .border(1.dp, Line, RoundedCornerShape(12.dp)) + .clickable(enabled = !refreshing) { onRefresh() }, + contentAlignment = Alignment.Center, + ) { + if (refreshing) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + color = Primary, + ) + } else { + Icon( + Icons.Filled.Refresh, + contentDescription = "刷新", + tint = InkSoft, + modifier = Modifier.size(20.dp), + ) + } + } + } +} + @Composable fun EmptyState(title: String, desc: String, modifier: Modifier = Modifier) { Column(