feat(knowledge): 知识能挂附件,新建时就能传
CI / backend (push) Canceled after 0s

知识是备忘录,备忘录不能只有字:手册、日志、导出表格都得能挂上去。
附件本来就落在 item 上(`ALLOWED_KINDS` 早含 knowledge),缺的是"新建时
还没有条目 id"这一段。

- 懒建草稿:`KnowledgeComposer` 第一次传附件(或往正文贴图)时先 `POST /knowledge`
  存一条草稿拿到 id,`draftingRef` 去重防止贴图与上传同时打出两条草稿;
  之后的保存走 `PATCH /knowledge/{id}`。没传过附件就取消,库里不留空条目。
- `RichTextEditor` 增 `ensureItemId`:没有 itemId 时先把草稿存下来再传图,
  于是"新建时不能贴图"这条限制也没了;工具栏上传按钮按是否有落点显隐。
- 新建面板加了附件区:有草稿 id 就是正常的 `AttachmentPanel`(上传/下载/删),
  没有就先给一个「添加附件」按钮,点一下建草稿。
- 安卓知识详情加附件区:列表(文件名 + 大小)、`OpenDocument` 上传任意文件、
  点「打开」用浏览器看签名直链。编辑器原本只能插图,现在手册/日志也能传。
- 文档:API.md 说明附件三类事项共用同一套接口,以及懒建草稿的先后顺序;
  ROADMAP 加 3.13。

验证:后端 pytest 94 passed、ruff 全绿;Web `tsc --noEmit` 通过、`vite build` 通过;
`knowledge-notebook.mjs` 15 项全绿(新增「新建面板里能上传附件」「详情抽屉里能看到
刚传的附件」「附件挂在知识上,详情回读得到」)。安卓端仍是 JDK 25 + Gradle 8.7
不兼容,本次未编译验证。
This commit is contained in:
Steven
2026-10-30 16:18:32 +08:00
parent ffae7e380f
commit 7c84ff3b8d
7 changed files with 250 additions and 14 deletions
@@ -1,6 +1,12 @@
package com.dadao.task.ui
import android.content.Intent
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.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -34,16 +40,20 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.dadao.task.data.Api
import com.dadao.task.data.ApiException
import com.dadao.task.data.Attachment
import com.dadao.task.data.KnowledgeCategory
import com.dadao.task.data.KnowledgeDoc
import com.dadao.task.data.Workspace
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
private val knowledgeTabs = listOf(
"draft" to "待核对",
@@ -459,12 +469,47 @@ private fun KnowledgeDetailDialog(
onEdit: (EditRequest) -> Unit,
) {
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 savingMeta by remember { mutableStateOf(false) }
var metaNotice by remember { mutableStateOf<String?>(null) }
/**
* 附件:备忘录不只文字,手册、日志、表格都要能挂上来。
* 附件必须落在条目上,所以这里只在已有的知识详情里传(新建时由正文里的编辑器负责)。
*/
var attachments by remember(doc.id) { mutableStateOf(doc.attachments) }
var uploading by remember { mutableStateOf(false) }
LaunchedEffect(doc.id) {
runCatching { api.attachments(workspace.id, doc.id) }.onSuccess { attachments = it }
}
val picker = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
if (uri == null) return@rememberLauncherForActivityResult
scope.launch {
uploading = true
try {
val bytes = withContext(Dispatchers.IO) {
context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
}
if (bytes == null) {
onFail(IllegalStateException("读不到这个文件,换个来源试试"))
return@launch
}
val name = attachmentName(context, uri) ?: "file-${System.currentTimeMillis()}"
val mime = context.contentResolver.getType(uri) ?: "application/octet-stream"
val created = api.uploadAttachment(workspace.id, doc.id, name, mime, bytes)
attachments = attachments + created
} catch (e: Exception) {
onFail(e)
} finally {
uploading = false
}
}
}
val actions = when (action) {
"draft" -> listOf("submit_review" to "提交审议", "publish" to "发布", "archive" to "归档")
"in_review" -> listOf("withdraw" to "撤回审议", "publish" to "发布", "archive" to "归档")
@@ -498,6 +543,50 @@ private fun KnowledgeDetailDialog(
color = InkSoft,
)
// 附件:手册、日志、导出表格都能挂在这条知识下,点一下用浏览器打开
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("打开") }
}
}
// 已发布的内容不能直接改,分类/标签也跟着锁住,避免"发布后又悄悄换归属"
if (action != "published") {
Spacer(Modifier.height(14.dp))
@@ -613,3 +702,20 @@ private fun KnowledgeDetailDialog(
containerColor = Paper,
)
}
/** 附件列表里显示的大小:人与手机都不需要精确到字节。 */
private fun humanSize(bytes: Long): String = when {
bytes < 1024 -> "$bytes B"
bytes < 1024 * 1024 -> "${bytes / 1024} KB"
else -> String.format("%.1f MB", bytes / 1024.0 / 1024.0)
}
/** 从 content:// 里问出文件名:微信、文件管理器给的 uri 形态各不相同,能问就问。 */
private fun attachmentName(context: android.content.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() ?: uri.lastPathSegment?.substringAfterLast('/')