知识是备忘录,备忘录不能只有字:手册、日志、导出表格都得能挂上去。
附件本来就落在 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:
@@ -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('/')
|
||||
|
||||
|
||||
Reference in New Issue
Block a user