任务/工单/知识列表补搜索与刷新(任务搜索改为停手 350ms 再请求);底栏五个入口配官方 Material 图标;知识正文改为整屏阅读页,不再用弹窗挤在 320dp 里;正文图片走 Markwon 渲染,相对地址补后端域名、附件链换 access token,超过 12MB 或加载失败不拖垮整篇正文。版本 0.5.0。
This commit is contained in:
@@ -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),
|
||||
)
|
||||
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,
|
||||
|
||||
@@ -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,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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 附件列表里显示的大小:人与手机都不需要精确到字节。 */
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user