feat(android): 列表刷新搜索、底栏图标、全屏阅读与内联图片
CI / backend (push) Waiting to run

任务/工单/知识列表补搜索与刷新(任务搜索改为停手 350ms 再请求);底栏五个入口配官方 Material 图标;知识正文改为整屏阅读页,不再用弹窗挤在 320dp 里;正文图片走 Markwon 渲染,相对地址补后端域名、附件链换 access token,超过 12MB 或加载失败不拖垮整篇正文。版本 0.5.0。
This commit is contained in:
Steven
2026-11-10 16:34:01 +08:00
parent d58cf7922e
commit 4cb054d32f
8 changed files with 642 additions and 263 deletions
+6 -2
View File
@@ -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")
}
@@ -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)
}
/**
* 工单动作。状态机在服务端,这里只发"做什么",不自己算目标状态——
@@ -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),
.background(
if (active) PrimarySoft else Color.Transparent,
RoundedCornerShape(10.dp),
)
Spacer(Modifier.height(6.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,
@@ -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<String?>(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<String?>(null) }
/**
@@ -1047,35 +1025,88 @@ 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 {
// 关键:默认 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)
}
Column(Modifier.weight(1f)) {
Text(
doc.title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
maxLines = 2,
)
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(8.dp))
Text(doc.title, style = MaterialTheme.typography.titleMedium)
}
},
text = {
Column(
// 阅读区给足高度:手机上一屏能看二十来行,不用一直往里滚
Modifier.heightIn(min = 320.dp, max = 560.dp).verticalScroll(rememberScrollState()),
}
TextButton(onClick = {
onEdit(
EditRequest(
itemId = doc.id,
title = doc.title,
body = doc.bodyMd,
version = version,
titleEnabled = action != "archived",
knowledge = true,
),
)
onClose()
}) { 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)
Spacer(Modifier.height(10.dp))
// 点开就是「看」:正文用 Markwon 排好版再显示,要看原文/改内容点「编辑正文」
KnowledgeBody(doc.bodyMd)
}
item {
Spacer(Modifier.height(12.dp))
MarkdownBody(doc.bodyMd, api)
}
// 附件:手册、日志、导出表格都能挂在这条知识下,点一下用浏览器打开
Spacer(Modifier.height(14.dp))
item {
Spacer(Modifier.height(20.dp))
SectionLabel("附件")
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
if (attachments.isEmpty()) "还没有附件" else "${attachments.size} 个",
when {
fileAttachments.isNotEmpty() -> "${fileAttachments.size} 个"
inlineIds.isNotEmpty() -> "图片已内嵌在正文中"
else -> "还没有附件"
},
style = MaterialTheme.typography.labelSmall,
color = InkMuted,
)
@@ -1085,7 +1116,7 @@ private fun KnowledgeDetailDialog(
onClick = { picker.launch(arrayOf("*/*")) },
) { Text(if (uploading) "上传中…" else "上传附件") }
}
attachments.forEach { row ->
fileAttachments.forEach { row ->
Row(
Modifier.fillMaxWidth().padding(vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically,
@@ -1105,7 +1136,8 @@ private fun KnowledgeDetailDialog(
TextButton(
onClick = {
// raw_url 是带短时签名的相对路径,拼上当前后端地址才能在浏览器里打开
val url = row.rawUrl?.let { api.currentOrigin() + it } ?: return@TextButton
val url = row.rawUrl?.let { api.currentOrigin() + it }
?: return@TextButton
runCatching {
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
}.onFailure { onFail(it) }
@@ -1113,10 +1145,12 @@ private fun KnowledgeDetailDialog(
) { Text("打开") }
}
}
}
// 已发布的也能改(后端改完会重建检索切片);归档的先恢复成草稿再动,免得"收起来的"被悄悄改写
if (action != "archived") {
Spacer(Modifier.height(14.dp))
item {
Spacer(Modifier.height(20.dp))
SectionLabel("分类")
Row(
modifier = Modifier.horizontalScroll(rememberScrollState()),
@@ -1160,24 +1194,18 @@ private fun KnowledgeDetailDialog(
}
}
}
},
confirmButton = {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
TextButton(
onClick = {
onEdit(
EditRequest(
itemId = doc.id,
title = doc.title,
body = doc.bodyMd,
version = version,
titleEnabled = action != "archived",
knowledge = true,
),
)
onClose()
},
) { Text("编辑正文") }
}
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,
@@ -1224,10 +1252,9 @@ private fun KnowledgeDetailDialog(
}) { Text(label) }
}
}
},
dismissButton = { TextButton(onClick = onClose) { Text("关闭") } },
containerColor = Paper,
)
}
}
}
}
/** 附件列表里显示的大小:人与手机都不需要精确到字节。 */
@@ -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<String> =
attachmentRefRe.findAll(markdown).map { it.groupValues[1] }.toSet()
/** 正文图片解码后的缓存:同一条知识反复进出阅读页时不必重新下载同一个文件。 */
private val imageCache = object : LruCache<String, Bitmap>(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("""(<img[^>]*\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<String> = 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),
)
}
@@ -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<String?>(null) }
var detailRefresh by remember { mutableStateOf(0) }
var error by remember { mutableStateOf<String?>(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 },
)
}
@@ -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<List<Ticket>>(emptyList()) }
var loading by remember { mutableStateOf(true) }
var message by remember { mutableStateOf<String?>(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) }
}
@@ -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(