feat(rich): 安卓能编辑富文本了,图片直链换成短期签名,表格不再撑破抽屉
CI / backend (push) Canceled after 0s

三件欠账一次清:

1. 图片直链签名化
   - 新增 `backend/app/services/signing.py`:HMAC-SHA256,密钥由 `JWT_SECRET` 域分离派生
     (`attachment-raw-v1`),载荷 `workspace_id:attachment_id:exp`,默认 30 分钟
   - `/raw` 支持 `?exp=&sig=`,不需要登录态;签名里绑了工作区,跨租户链接直接拒
   - 附件对象多一个 `raw_url`,前端每次渲染现签,正文里只存不带凭据的 `/raw`
   - 保留旧 `?token=` 通道,给未升级的客户端过渡

2. 安卓富文本编辑器
   - `ui/RichTextEditorScreen.kt`:Markdown 工具栏(粗体/斜体/标题/列表/引用/代码/链接/图片),
     选区包裹与行首前缀都按光标位置来;贴图走附件接口,插入签名直链
   - 预览用 Markwon,图片用 Coil;正文落在一张白纸卡片里,不再贴屏幕边
   - 从任务详情与知识点都能进,保存按 `body_md` 回写(PATCH 带 version)

3. 表格
   - 只读渲染走 markdown-it 的 GFM 表,`display:block + overflow-x:auto`:
     宽表横向滚动、窄表铺满,都不撑破抽屉

验证:pytest 74 passed;richtext 15 / smoke 7 / tickets 8 / knowledge 7 全绿;
tsc -b 0 错;vite build 通过(主包 299.42 kB / gzip 74.96 kB);
安卓 assembleDebug 成功并在 API 35 模拟器走通编辑与预览。

仍欠:保存后回列表而非回详情抽屉、图片压缩与缩略图、HTML 片段按文本显示。
This commit is contained in:
dadao
2026-10-29 03:32:53 +08:00
parent fbdf54cb4c
commit 17be9d1d42
24 changed files with 923 additions and 99 deletions
@@ -19,12 +19,15 @@ import androidx.compose.ui.platform.LocalContext
import com.dadao.task.data.Api
import com.dadao.task.data.TokenStore
import com.dadao.task.data.Workspace
import androidx.compose.foundation.layout.Box
import com.dadao.task.ui.BoardScreen
import com.dadao.task.ui.DadaoTheme
import com.dadao.task.ui.EditRequest
import com.dadao.task.ui.HomeScreen
import com.dadao.task.ui.KnowledgeScreen
import com.dadao.task.ui.LoginScreen
import com.dadao.task.ui.MembersScreen
import com.dadao.task.ui.RichTextEditorScreen
import com.dadao.task.ui.TaskListScreen
import com.dadao.task.ui.TicketsScreen
import com.dadao.task.ui.WorkspaceScaffold
@@ -68,6 +71,8 @@ private fun AppRoot(joinUri: String?) {
var current by remember { mutableStateOf<Workspace?>(null) }
var tab by remember { mutableStateOf(WorkspaceTab.Tasks) }
var unread by remember { mutableStateOf(0) }
// 打开富文本编辑器时非空;关掉就恢复原来的界面
var editing by remember { mutableStateOf<EditRequest?>(null) }
// 有本地令牌就先静默校验一次:令牌过期/被吊销要退回登录页,而不是进了主页再崩。
LaunchedEffect(screen) {
@@ -122,18 +127,62 @@ private fun AppRoot(joinUri: String?) {
if (ws == null) {
screen = Screen.Home
} else {
WorkspaceScaffold(
workspace = ws,
tab = tab,
unread = unread,
onTab = { tab = it },
onBackHome = { screen = Screen.Home },
) {
when (tab) {
WorkspaceTab.Tasks -> TaskListScreen(api = api, workspace = ws)
WorkspaceTab.Tickets -> TicketsScreen(api = api, workspace = ws)
WorkspaceTab.Knowledge -> KnowledgeScreen(api = api, workspace = ws)
WorkspaceTab.Members -> MembersScreen(api = api, workspace = ws)
Box {
WorkspaceScaffold(
workspace = ws,
tab = tab,
unread = unread,
onTab = { tab = it },
onBackHome = { screen = Screen.Home },
) {
when (tab) {
WorkspaceTab.Tasks -> TaskListScreen(
api = api,
workspace = ws,
onEdit = { editing = it },
)
WorkspaceTab.Tickets -> TicketsScreen(api = api, workspace = ws)
WorkspaceTab.Knowledge -> KnowledgeScreen(
api = api,
workspace = ws,
onEdit = { editing = it },
)
WorkspaceTab.Members -> MembersScreen(api = api, workspace = ws)
}
}
val request = editing
if (request != null) {
RichTextEditorScreen(
api = api,
workspaceId = ws.id,
itemId = request.itemId,
initialTitle = request.title,
initialBody = request.body,
titleEnabled = request.titleEnabled,
save = { title, body ->
if (request.knowledge) {
api.updateKnowledge(
workspaceId = ws.id,
itemId = request.itemId,
version = request.version,
title = title,
body = body,
)
} else {
api.updateItem(
workspaceId = ws.id,
itemId = request.itemId,
version = request.version,
title = title,
body = body,
)
}
},
onClose = { editing = null },
)
}
}
}
@@ -10,6 +10,8 @@ import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
@@ -136,8 +138,29 @@ data class ItemDetail(
val tags: List<Tag> = emptyList(),
val version: Int = 1,
val activities: List<Activity> = emptyList(),
val attachments: List<Attachment> = emptyList(),
)
/**
* 附件。[rawUrl] 是后端签发的短时直链(带 exp/sig),
* 正文里的图片就用它,**不要**把长期凭据写进正文。
*/
@Serializable
data class Attachment(
val id: String,
@SerialName("item_id") val itemId: String = "",
val filename: String = "",
@SerialName("content_type") val contentType: String = "application/octet-stream",
val size: Long = 0,
@SerialName("uploader_id") val uploaderId: String = "",
@SerialName("created_at") val createdAt: String = "",
@SerialName("raw_url") val rawUrl: String? = null,
@SerialName("can_delete") val canDelete: Boolean = false,
)
@Serializable
data class AttachmentPage(val items: List<Attachment> = emptyList())
@Serializable
data class Invitation(
val code: String,
@@ -223,6 +246,7 @@ data class KnowledgeDoc(
val tags: List<Tag> = emptyList(),
val knowledge: KnowledgeInfo = KnowledgeInfo(),
val source: SourceRef? = null,
val attachments: List<Attachment> = emptyList(),
)
@Serializable
@@ -287,6 +311,13 @@ private data class KnowledgeCreateRequest(
@Serializable
private data class KnowledgeActionRequest(val action: String)
@Serializable
private data class ItemUpdateRequest(
val version: Int,
val title: String? = null,
@SerialName("body_md") val bodyMd: String? = null,
)
@Serializable
private data class KnowledgeUpdateRequest(
val version: Int,
@@ -451,6 +482,67 @@ class Api(private val tokens: TokenStore) {
auth = true,
)
// ---- 附件与富文本 ----
suspend fun attachments(workspaceId: String, itemId: String): List<Attachment> =
call<AttachmentPage>(
path = "/workspaces/$workspaceId/items/$itemId/attachments",
auth = true,
).items
/**
* 上传附件。返回的 [Attachment.rawUrl] 是短时签名直链,
* 直接写进 Markdown 的 `![](...)` 即可,不需要把登录令牌塞进正文。
*/
suspend fun uploadAttachment(
workspaceId: String,
itemId: String,
filename: String,
mimeType: String,
bytes: ByteArray,
): Attachment = withContext(Dispatchers.IO) {
val form = MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addFormDataPart(
"file",
filename,
bytes.toRequestBody(mimeType.toMediaTypeOrNull()),
)
.build()
val builder = Request.Builder()
.url("$base/workspaces/$workspaceId/items/$itemId/attachments")
.post(form)
.header("Accept", "application/json")
tokens.accessToken?.let { builder.header("Authorization", "Bearer $it") }
try {
http.newCall(builder.build()).execute().use { response ->
val text = response.body?.string().orEmpty()
val envelope = runCatching { json.decodeFromString<Envelope<Attachment>>(text) }
.getOrElse { throw ApiException(1500, "上传失败(HTTP ${response.code})") }
if (envelope.code != 0) throw ApiException(envelope.code, envelope.message, envelope.requestId)
envelope.data ?: throw ApiException(1500, "上传响应缺少 data")
}
} catch (e: IOException) {
throw unreachable(e)
}
}
/** 保存正文/标题。PATCH 必须带 version,冲突由服务端返回 4009。 */
suspend fun updateItem(
workspaceId: String,
itemId: String,
version: Int,
title: String? = null,
body: String? = null,
): ItemDetail = call(
path = "/workspaces/$workspaceId/items/$itemId",
body = json.encodeToString(ItemUpdateRequest(version, title, body)),
auth = true,
method = "PATCH",
)
// ---- M2 工单 ----
/** role: assigned(派给我的)/ requested(我发起的)/ all。 */
@@ -55,7 +55,7 @@ private fun docTypeLabel(type: String): String = when (type) {
}
@Composable
fun KnowledgeScreen(api: Api, workspace: Workspace) {
fun KnowledgeScreen(api: Api, workspace: Workspace, onEdit: (EditRequest) -> Unit = {}) {
val scope = rememberCoroutineScope()
var tab by remember { mutableStateOf("draft") }
var docs by remember { mutableStateOf<List<KnowledgeDoc>>(emptyList()) }
@@ -168,6 +168,7 @@ fun KnowledgeScreen(api: Api, workspace: Workspace) {
workspace = workspace,
doc = target,
onClose = { open = null },
onEdit = onEdit,
onChanged = {
scope.launch { reload() }
},
@@ -249,6 +250,7 @@ private fun KnowledgeDetailDialog(
onClose: () -> Unit,
onChanged: () -> Unit,
onFail: (Throwable) -> Unit,
onEdit: (EditRequest) -> Unit,
) {
val scope = rememberCoroutineScope()
var action by remember { mutableStateOf(doc.status) }
@@ -288,6 +290,21 @@ private fun KnowledgeDetailDialog(
},
confirmButton = {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
TextButton(
onClick = {
onEdit(
EditRequest(
itemId = doc.id,
title = doc.title,
body = doc.bodyMd,
version = doc.version,
titleEnabled = action == "draft",
knowledge = true,
),
)
onClose()
},
) { Text("编辑正文") }
actions.forEach { (key, label) ->
TextButton(onClick = {
scope.launch {
@@ -0,0 +1,428 @@
package com.dadao.task.ui
import android.content.Context
import android.net.Uri
import android.provider.OpenableColumns
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
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.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import coil.compose.AsyncImage
import com.dadao.task.data.Api
import com.dadao.task.data.ApiException
import com.dadao.task.data.Attachment
import io.noties.markwon.Markwon
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/** 打开编辑器需要的最小上下文:改的是哪条、改前是什么。 */
data class EditRequest(
val itemId: String,
val title: String,
val body: String,
val version: Int,
val titleEnabled: Boolean = true,
val knowledge: Boolean = false,
)
/**
* 富文本编辑器(Markdown 存储)。
*
* 为什么存 Markdown 而不是 HTML:任务备注、工单说明、知识正文都要能被检索、
* 被 LLM 读、被导出;HTML 里混着行内样式,清洗成本远高于收益。
* 因此编辑区写 Markdown,预览用 Markwon 渲染,图片传附件后用签名直链插入。
*/
@Composable
fun RichTextEditorScreen(
api: Api,
workspaceId: String,
itemId: String,
initialTitle: String,
initialBody: String,
/** 保存动作由调用方决定:事项走 PATCH /items,知识走 PATCH /knowledge。 */
save: suspend (title: String?, body: String) -> Unit,
titleEnabled: Boolean = true,
onClose: (saved: Boolean) -> Unit,
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
var title by remember { mutableStateOf(initialTitle) }
var field by remember { mutableStateOf(TextFieldValue(initialBody)) }
var preview by remember { mutableStateOf(false) }
var saving by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) }
var images by remember { mutableStateOf(emptyList<Attachment>()) }
// 传过的图片:进来先拉一份,预览区与「图片」栏都用它
LaunchedEffect(itemId) {
runCatching { api.attachments(workspaceId, itemId) }
.onSuccess { images = it.filter { row -> row.contentType.startsWith("image/") } }
}
val picker = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
if (uri == null) return@rememberLauncherForActivityResult
scope.launch {
saving = true
try {
val bytes = withContext(Dispatchers.IO) {
context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
}
if (bytes == null) {
error = "读不到这张图片,换个来源试试"
return@launch
}
val name = displayName(context, uri) ?: "image-${System.currentTimeMillis()}.png"
val mime = context.contentResolver.getType(uri) ?: "image/png"
val created = api.uploadAttachment(workspaceId, itemId, name, mime, bytes)
images = images + created
// 插入用签名直链:它会过期,所以库里存的是干净的相对路径
field = insert(field, "![${created.filename}](${created.rawUrl.orEmpty()})\n")
} catch (e: ApiException) {
error = e.message
} catch (e: Exception) {
error = "上传失败:${e.message ?: "未知错误"}"
} finally {
saving = false
}
}
}
Column(
Modifier
.fillMaxSize()
.background(Paper)
.statusBarsPadding(),
) {
// 顶部:取消 / 状态 / 预览 / 保存
Row(
Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
GhostButton("取消", onClick = { onClose(false) })
Spacer(Modifier.width(10.dp))
Column(Modifier.weight(1f)) {
Text(
if (preview) "预览" else "编辑",
fontSize = 15.sp,
fontWeight = FontWeight.SemiBold,
color = Ink,
)
Text(
"${field.text.length} 字 · Markdown",
fontSize = 11.sp,
color = InkMuted,
)
}
if (saving) {
CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp, color = Primary)
Spacer(Modifier.width(8.dp))
}
PrimaryButton(if (preview) "继续写" else "预览", onClick = { preview = !preview })
Spacer(Modifier.width(8.dp))
PrimaryButton(
"保存",
enabled = !saving,
onClick = {
scope.launch {
saving = true
error = null
try {
save(if (titleEnabled) title else null, field.text)
onClose(true)
} catch (e: ApiException) {
error = if (e.code == 4009) "刚被别人改过,请退出重进再改一次" else e.message
} catch (e: Exception) {
error = e.message ?: "保存失败"
} finally {
saving = false
}
}
},
)
}
HorizontalDivider(color = Line)
error?.let {
Text(
it,
color = Rust,
fontSize = 13.sp,
modifier = Modifier
.fillMaxWidth()
.background(RustSoft)
.padding(horizontal = 14.dp, vertical = 8.dp),
)
}
// 编辑区做成一张纸:标题、工具栏、正文在同一张纸上,边界清楚
Box(
Modifier
.fillMaxSize()
.padding(horizontal = 12.dp, vertical = 10.dp),
) {
Column(
Modifier
.fillMaxSize()
.clip(RoundedCornerShape(14.dp))
.background(Color.White)
.border(1.dp, Line, RoundedCornerShape(14.dp)),
) {
if (titleEnabled) {
BasicTextField(
value = title,
onValueChange = { title = it },
singleLine = true,
textStyle = TextStyle(
fontSize = 19.sp,
fontWeight = FontWeight.Bold,
color = Ink,
),
cursorBrush = SolidColor(Primary),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 12.dp),
decorationBox = { inner ->
if (title.isBlank()) Text("标题", fontSize = 19.sp, color = InkMuted)
inner()
},
)
HorizontalDivider(color = Line)
} else if (preview) {
Text(
title.ifBlank { initialTitle },
fontSize = 19.sp,
fontWeight = FontWeight.Bold,
color = Ink,
modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp),
)
HorizontalDivider(color = Line)
}
if (preview) {
PreviewBody(field.text, images, Modifier.weight(1f))
} else {
Row(
Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState())
.padding(horizontal = 10.dp, vertical = 6.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
ToolButton("B", bold = true) { field = wrap(field, "**") }
ToolButton("I", italic = true) { field = wrap(field, "*") }
ToolButton("标题") { field = prefixLines(field, "## ") }
ToolButton("列表") { field = prefixLines(field, "- ") }
ToolButton("引用") { field = prefixLines(field, "> ") }
ToolButton("代码") { field = wrap(field, "`") }
ToolButton("链接") { field = insert(field, "[文字](https://)") }
ToolButton("图片") { picker.launch("image/*") }
}
HorizontalDivider(color = Line)
Box(
Modifier
.fillMaxSize()
.padding(horizontal = 14.dp, vertical = 12.dp),
) {
BasicTextField(
value = field,
onValueChange = { field = it },
textStyle = TextStyle(fontSize = 15.sp, color = Ink, lineHeight = 24.sp),
cursorBrush = SolidColor(Primary),
modifier = Modifier
.fillMaxSize()
.navigationBarsPadding(),
decorationBox = { inner ->
if (field.text.isBlank()) {
Text(
"写点什么。「B」「列表」在光标处插语法,图片直接选会自动上传。",
fontSize = 15.sp,
color = InkMuted,
lineHeight = 24.sp,
)
}
inner()
},
)
}
}
}
}
}
}
@Composable
private fun PreviewBody(
source: String,
images: List<Attachment>,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val markwon = remember { Markwon.create(context) }
val text = remember(source) {
// 预览里图片单独排一栏(Markwon 核心版不联网取图),正文里的图片语法换成占位说明
source.replace(Regex("!\\[[^\\]]*\\]\\([^)]*\\)"), "〔图片〕")
}
Column(
modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(horizontal = 14.dp, vertical = 12.dp)
.navigationBarsPadding(),
) {
if (images.isNotEmpty()) {
Row(
Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
images.forEach { image ->
AsyncImage(
model = image.rawUrl,
contentDescription = image.filename,
modifier = Modifier
.size(96.dp)
.clip(RoundedCornerShape(10.dp)),
)
}
}
Spacer(Modifier.height(12.dp))
}
if (text.isBlank()) {
Text("还没有内容", fontSize = 14.sp, color = InkMuted)
} else {
AndroidView(
factory = { ctx ->
android.widget.TextView(ctx).apply {
setTextIsSelectable(true)
textSize = 15f
setPadding(0, 0, 0, 0)
setLineSpacing(0f, 1.5f)
setTextColor(android.graphics.Color.parseColor("#4A5854"))
markwon.setMarkdown(this, text)
}
},
update = { view -> markwon.setMarkdown(view, text) },
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 120.dp),
)
}
}
}
/** 工具条按钮:小、方、无多余留白,横滑容得下。 */
@Composable
private fun ToolButton(
label: String,
bold: Boolean = false,
italic: Boolean = false,
onClick: () -> Unit,
) {
Box(
Modifier
.clip(RoundedCornerShape(8.dp))
.background(PaperSunken)
.clickable { onClick() }
.padding(horizontal = 10.dp, vertical = 6.dp),
) {
Text(
label,
fontSize = 13.sp,
color = InkSoft,
fontWeight = if (bold) FontWeight.Bold else FontWeight.Medium,
fontStyle = if (italic) FontStyle.Italic else null,
)
}
}
/** 把选区包起来:没选就插一对并落光标在中间,选了就把选中的包进去。 */
private fun wrap(field: TextFieldValue, token: String): TextFieldValue {
val start = field.selection.start.coerceIn(0, field.text.length)
val end = field.selection.end.coerceIn(start, field.text.length)
val picked = field.text.substring(start, end)
val next = field.text.substring(0, start) + token + picked + token + field.text.substring(end)
val caret = if (picked.isEmpty()) start + token.length else start + picked.length + token.length * 2
return field.copy(text = next, selection = TextRange(caret))
}
/** 行首插前缀(标题/列表/引用),一次处理选区覆盖的每一行。 */
private fun prefixLines(field: TextFieldValue, prefix: String): TextFieldValue {
val start = field.selection.start.coerceIn(0, field.text.length)
val end = field.selection.end.coerceIn(start, field.text.length)
val lineStart = field.text.lastIndexOf('\n', (start - 1).coerceAtLeast(0)).let {
if (it < 0 || start == 0) 0 else it + 1
}
val before = field.text.substring(0, lineStart)
val block = field.text.substring(lineStart, end)
val after = field.text.substring(end)
val nextBlock = block.split('\n').joinToString("\n") { line ->
if (line.startsWith(prefix)) line else prefix + line
}
val next = before + nextBlock + after
return field.copy(text = next, selection = TextRange(lineStart + nextBlock.length))
}
private fun insert(field: TextFieldValue, snippet: String): TextFieldValue {
val start = field.selection.start.coerceIn(0, field.text.length)
val end = field.selection.end.coerceIn(start, field.text.length)
val next = field.text.substring(0, start) + snippet + field.text.substring(end)
return field.copy(text = next, selection = TextRange(start + snippet.length))
}
private fun displayName(context: Context, uri: Uri): String? = runCatching {
context.contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)
?.use { cursor -> if (cursor.moveToFirst()) cursor.getString(0) else null }
}.getOrNull()
@@ -66,6 +66,8 @@ fun TaskDetailSheet(
itemId: String,
onClose: () -> Unit,
onChanged: () -> Unit,
/** 进富文本编辑器改正文。标题与状态留在抽屉里,只有正文要更大地方。 */
onEdit: (ItemDetail) -> Unit = {},
) {
val sheet = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val scope = rememberCoroutineScope()
@@ -125,6 +127,8 @@ fun TaskDetailSheet(
)
Spacer(Modifier.width(6.dp))
if (d.kind == "ticket") SoftPill("工单", Clay, ClaySoft)
Spacer(Modifier.weight(1f))
GhostButton("编辑正文", onClick = { onEdit(d) })
}
Spacer(Modifier.height(8.dp))
OutlinedTextField(
@@ -51,7 +51,7 @@ private val views = listOf(
)
@Composable
fun TaskListScreen(api: Api, workspace: Workspace) {
fun TaskListScreen(api: Api, workspace: Workspace, onEdit: (EditRequest) -> Unit = {}) {
val scope = rememberCoroutineScope()
var view by remember { mutableStateOf("active") }
var mode by remember { mutableStateOf("list") }
@@ -193,6 +193,10 @@ fun TaskListScreen(api: Api, workspace: Workspace) {
itemId = id,
onClose = { openId = null },
onChanged = { scope.launch { reload() } },
onEdit = {
openId = null
onEdit(EditRequest(it.id, it.title, it.bodyMd, it.version))
},
)
}
}