知识以前只能"填个标题就完成",正文、标签、归类都要点进去再补。现在新建 就是一张完整的备忘录:标题、类型、分类、标签、正文一次写完,存完直接打开 详情继续编辑;知识页左侧多了工作区三级分类树,可按分类子树 / 未分类 / 标签 筛选。凡是填信息的抽屉都加宽了,还能一键展开整屏。 后端 - 新增 `knowledge_category`(迁移 0007):`workspace_id NOT NULL` + 自引用 `parent_id`、`depth` 1..3 检查约束、同层重名唯一索引(lower(name))、 RLS 策略与 `dadao_app` 授权;`knowledge_doc.category_id` 外键 ON DELETE SET NULL。 - 新增 `/knowledge/categories` 增删改查:超三级 `7007`、同层重名 `7008`、 跨租户或不存在 `7006`;`move=true` 才改父级,成环与移动后超过三级的子树 一律拒绝,删除有知识的分类要 `force=true`(知识变未分类)。 - 知识列表支持 `category_id`(含子树)、`uncategorized`、`tag`;输出带 `category`(含 `path` / `full_path`)。 - PATCH 只改分类或标签也会推进 `version`,避免"只动标签"之后下次保存撞 乐观锁 `4009`。 Web - `Sheet` 增 `size="md/lg/xl"` 与展开整屏按钮;任务、工单、知识抽屉统一 加宽(`TaskDetail` / `Tickets` / `Knowledge`)。 - 知识页:左侧三级分类树(新建、按子树筛选、未分类)、`KnowledgeComposer` 一次写全新的备忘录、列表卡片露出分类路径与标签、详情抽屉里可直接改分类 与标签。 - 抽屉详情只拉一次:重复的 effect 或迟到的响应不再覆盖用户正在敲的内容。 安卓 - `Api.kt`:分类模型与接口、新建/更新带 `tag_names`/`category_id`、 列表带分类与标签筛选。 - 知识页重做:新建改为近全屏大对话框(标题/类型/分类/标签/正文),列表 顶部按分类筛选,详情里能改分类与标签后单独保存。 文档与测试 - `2.Design/API.md`:5.1/5.3/5.4 补分类与筛选参数,新增 5.5 分类接口章节, 错误码补 `7006`/`7007`/`7008`。 - `2.Design/ROADMAP-M0-M1.md`:新增 3.11(三级分类)、3.12(备忘录形态)。 - 新增 `backend/tests/test_knowledge_categories.py`(8 例):备忘形态落库、 三级到底、移动越界与成环、重名、按子树/未分类/标签/关键词筛选、删分类需 `force`、跨租户隔离。 - 新增 `web/e2e/knowledge-notebook.mjs`(10 项,全绿):建两级分类 → 一次 写全 → 卡片与筛选 → 抽屉改标签/分类 → 正文落库 → 重名 7008。 验证:`pytest` 93 passed、`ruff check` / `ruff format --check` 全过、 `tsc --noEmit` 通过、`vite build` 通过、`knowledge-notebook.mjs` 10 项全绿。 安卓端本机只有 JDK 25,Gradle 8.7 / AGP 8.7.3 不支持,本次未做编译验证。
This commit is contained in:
@@ -236,6 +236,26 @@ data class SourceRef(
|
||||
val status: String = "",
|
||||
)
|
||||
|
||||
/** 知识分类:工作区级的三级小树,fullPath 形如「研发 / 部署 / 回滚」。 */
|
||||
@Serializable
|
||||
data class KnowledgeCategory(
|
||||
val id: String,
|
||||
val name: String = "",
|
||||
@SerialName("parent_id") val parentId: String? = null,
|
||||
val depth: Int = 1,
|
||||
@SerialName("sort_order") val sortOrder: Int = 0,
|
||||
val version: Int = 1,
|
||||
val path: List<String> = emptyList(),
|
||||
@SerialName("full_path") val fullPath: String = "",
|
||||
@SerialName("doc_count") val docCount: Int = 0,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class KnowledgeCategoryPage(
|
||||
val items: List<KnowledgeCategory> = emptyList(),
|
||||
@SerialName("max_depth") val maxDepth: Int = 3,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class KnowledgeDoc(
|
||||
val id: String,
|
||||
@@ -245,6 +265,7 @@ data class KnowledgeDoc(
|
||||
val version: Int = 1,
|
||||
val tags: List<Tag> = emptyList(),
|
||||
val knowledge: KnowledgeInfo = KnowledgeInfo(),
|
||||
val category: KnowledgeCategory? = null,
|
||||
val source: SourceRef? = null,
|
||||
val attachments: List<Attachment> = emptyList(),
|
||||
)
|
||||
@@ -325,6 +346,8 @@ private data class KnowledgeCreateRequest(
|
||||
val title: String,
|
||||
@SerialName("body_md") val bodyMd: String,
|
||||
@SerialName("doc_type") val docType: String,
|
||||
@SerialName("tag_names") val tagNames: List<String> = emptyList(),
|
||||
@SerialName("category_id") val categoryId: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
@@ -351,6 +374,10 @@ private data class KnowledgeUpdateRequest(
|
||||
val version: Int,
|
||||
val title: String? = null,
|
||||
@SerialName("body_md") val bodyMd: String? = null,
|
||||
@SerialName("tag_names") val tagNames: List<String>? = null,
|
||||
@SerialName("category_id") val categoryId: String? = null,
|
||||
// 想把分类清成"未分类"就传 clear_category=true:只传 null 会被服务端当成"不改"
|
||||
@SerialName("clear_category") val clearCategory: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -666,20 +693,39 @@ class Api(private val tokens: TokenStore) {
|
||||
workspaceId: String,
|
||||
statuses: List<String> = listOf("draft", "in_review"),
|
||||
query: String? = null,
|
||||
categoryId: String? = null,
|
||||
uncategorized: Boolean = false,
|
||||
): KnowledgePage {
|
||||
val params = buildString {
|
||||
append("?limit=30")
|
||||
statuses.forEach { append("&status=").append(it) }
|
||||
if (!query.isNullOrBlank()) append("&q=").append(java.net.URLEncoder.encode(query, "UTF-8"))
|
||||
if (!query.isNullOrBlank()) {
|
||||
append("&q=").append(java.net.URLEncoder.encode(query, "UTF-8"))
|
||||
}
|
||||
if (categoryId != null) append("&category_id=").append(categoryId)
|
||||
if (uncategorized) append("&uncategorized=true")
|
||||
}
|
||||
return call(path = "/workspaces/$workspaceId/knowledge$params", auth = true)
|
||||
}
|
||||
|
||||
/** 工作区三级分类小树(扁平返回,depth 表示层级)。 */
|
||||
suspend fun knowledgeCategories(workspaceId: String): KnowledgeCategoryPage =
|
||||
call(path = "/workspaces/$workspaceId/knowledge/categories", auth = true)
|
||||
|
||||
/** 手记:没有来源的知识,得自己写清楚。 */
|
||||
suspend fun createKnowledge(workspaceId: String, title: String, body: String = ""): KnowledgeDoc =
|
||||
suspend fun createKnowledge(
|
||||
workspaceId: String,
|
||||
title: String,
|
||||
body: String = "",
|
||||
docType: String = "note",
|
||||
tags: List<String> = emptyList(),
|
||||
categoryId: String? = null,
|
||||
): KnowledgeDoc =
|
||||
call(
|
||||
path = "/workspaces/$workspaceId/knowledge",
|
||||
body = json.encodeToString(KnowledgeCreateRequest(title, body, "note")),
|
||||
body = json.encodeToString(
|
||||
KnowledgeCreateRequest(title, body, docType, tags, categoryId),
|
||||
),
|
||||
auth = true,
|
||||
)
|
||||
|
||||
@@ -709,9 +755,14 @@ class Api(private val tokens: TokenStore) {
|
||||
version: Int,
|
||||
title: String? = null,
|
||||
body: String? = null,
|
||||
tags: List<String>? = null,
|
||||
categoryId: String? = null,
|
||||
clearCategory: Boolean = false,
|
||||
): KnowledgeDoc = call(
|
||||
path = "/workspaces/$workspaceId/knowledge/$itemId",
|
||||
body = json.encodeToString(KnowledgeUpdateRequest(version, title, body)),
|
||||
body = json.encodeToString(
|
||||
KnowledgeUpdateRequest(version, title, body, tags, categoryId, clearCategory),
|
||||
),
|
||||
auth = true,
|
||||
method = "PATCH",
|
||||
)
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
package com.dadao.task.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
@@ -33,8 +36,11 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
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.KnowledgeCategory
|
||||
import com.dadao.task.data.KnowledgeDoc
|
||||
import com.dadao.task.data.Workspace
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -46,13 +52,26 @@ private val knowledgeTabs = listOf(
|
||||
"all" to "全部",
|
||||
)
|
||||
|
||||
private fun docTypeLabel(type: String): String = when (type) {
|
||||
"howto" -> "怎么做"
|
||||
"faq" -> "问答"
|
||||
"postmortem" -> "复盘"
|
||||
"decision" -> "决策"
|
||||
else -> "笔记"
|
||||
}
|
||||
private val docTypes = listOf(
|
||||
"note" to "笔记",
|
||||
"howto" to "怎么做",
|
||||
"faq" to "问答",
|
||||
"postmortem" to "复盘",
|
||||
"decision" to "决策",
|
||||
)
|
||||
|
||||
private fun docTypeLabel(type: String): String =
|
||||
docTypes.firstOrNull { it.first == type }?.second ?: "笔记"
|
||||
|
||||
private fun parseTags(raw: String): List<String> =
|
||||
raw.split(' ', '\n', ',', ',', '、')
|
||||
.map { it.trim().removePrefix("#") }
|
||||
.filter { it.isNotBlank() }
|
||||
.distinct()
|
||||
|
||||
/** 分类在筛选条里的显示名:用点前缀表达层级,手机上比缩进更稳。 */
|
||||
private fun catLabel(cat: KnowledgeCategory): String =
|
||||
"·".repeat((cat.depth - 1).coerceAtLeast(0)) + cat.name
|
||||
|
||||
@Composable
|
||||
fun KnowledgeScreen(
|
||||
@@ -66,12 +85,18 @@ fun KnowledgeScreen(
|
||||
val scope = rememberCoroutineScope()
|
||||
var tab by remember { mutableStateOf("draft") }
|
||||
var docs by remember { mutableStateOf<List<KnowledgeDoc>>(emptyList()) }
|
||||
var categories by remember { mutableStateOf<List<KnowledgeCategory>>(emptyList()) }
|
||||
var loading by remember { mutableStateOf(true) }
|
||||
var message by remember { mutableStateOf<String?>(null) }
|
||||
var open by remember { mutableStateOf<KnowledgeDoc?>(null) }
|
||||
var adding by remember { mutableStateOf(false) }
|
||||
var newTitle by remember { mutableStateOf("") }
|
||||
var newBody by remember { mutableStateOf("") }
|
||||
// null = 全部;"none" = 未分类;其它是分类 id(服务端含子分类)
|
||||
var catFilter by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
suspend fun loadCategories() {
|
||||
runCatching { api.knowledgeCategories(workspace.id) }
|
||||
.onSuccess { categories = it.items }
|
||||
}
|
||||
|
||||
suspend fun reload() {
|
||||
loading = true
|
||||
@@ -81,7 +106,15 @@ fun KnowledgeScreen(
|
||||
"all" -> listOf("draft", "in_review", "published", "archived")
|
||||
else -> listOf("draft", "in_review")
|
||||
}
|
||||
runCatching { api.knowledge(workspace.id, statuses) }
|
||||
val filter = catFilter
|
||||
runCatching {
|
||||
api.knowledge(
|
||||
workspace.id,
|
||||
statuses,
|
||||
categoryId = if (filter != null && filter != "none") filter else null,
|
||||
uncategorized = filter == "none",
|
||||
)
|
||||
}
|
||||
.onSuccess {
|
||||
docs = it.items
|
||||
message = null
|
||||
@@ -90,7 +123,8 @@ fun KnowledgeScreen(
|
||||
loading = false
|
||||
}
|
||||
|
||||
LaunchedEffect(tab) { reload() }
|
||||
LaunchedEffect(tab, catFilter) { reload() }
|
||||
LaunchedEffect(Unit) { loadCategories() }
|
||||
|
||||
// 从编辑器保存回来:本页是新建出来的,刷完列表再把那条详情重新打开。
|
||||
LaunchedEffect(reopenItem) {
|
||||
@@ -125,11 +159,30 @@ fun KnowledgeScreen(
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(10.dp))
|
||||
GhostButton("记一条", onClick = {
|
||||
adding = true
|
||||
newTitle = ""
|
||||
newBody = ""
|
||||
})
|
||||
Row(
|
||||
modifier = Modifier.horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
SurfaceChip(
|
||||
text = "全部分类",
|
||||
selected = catFilter == null,
|
||||
onClick = { catFilter = null },
|
||||
)
|
||||
SurfaceChip(
|
||||
text = "未分类",
|
||||
selected = catFilter == "none",
|
||||
onClick = { catFilter = "none" },
|
||||
)
|
||||
categories.forEach { cat ->
|
||||
SurfaceChip(
|
||||
text = catLabel(cat),
|
||||
selected = catFilter == cat.id,
|
||||
onClick = { catFilter = cat.id },
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(10.dp))
|
||||
GhostButton("记一条", onClick = { adding = true })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,7 +194,7 @@ fun KnowledgeScreen(
|
||||
if (!loading && docs.isEmpty()) {
|
||||
item {
|
||||
EmptyState(
|
||||
"还没有待核对的知识",
|
||||
if (catFilter == null) "还没有待核对的知识" else "这个分类下还没有知识",
|
||||
"把工单验收通过,或在任务详情里点「沉淀成知识」",
|
||||
)
|
||||
}
|
||||
@@ -165,6 +218,18 @@ fun KnowledgeScreen(
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
val meta = buildList {
|
||||
doc.category?.let { add(it.fullPath.ifBlank { it.name }) }
|
||||
doc.tags.forEach { add("#${it.name}") }
|
||||
}
|
||||
if (meta.isNotEmpty()) {
|
||||
Text(
|
||||
meta.joinToString(" "),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = Primary,
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
}
|
||||
Text(
|
||||
sourceLine(doc),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
@@ -182,68 +247,35 @@ fun KnowledgeScreen(
|
||||
api = api,
|
||||
workspace = workspace,
|
||||
doc = target,
|
||||
categories = categories,
|
||||
onClose = { open = null },
|
||||
onEdit = onEdit,
|
||||
onChanged = {
|
||||
scope.launch { reload() }
|
||||
scope.launch {
|
||||
reload()
|
||||
loadCategories()
|
||||
}
|
||||
},
|
||||
onCategoriesChanged = { scope.launch { loadCategories() } },
|
||||
onFail = { fail(it) },
|
||||
)
|
||||
}
|
||||
|
||||
if (adding) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { adding = false },
|
||||
title = { Text("记一条", style = MaterialTheme.typography.titleMedium) },
|
||||
text = {
|
||||
Column {
|
||||
Text(
|
||||
"手记没有来源,想沉淀某件具体的事,去那条任务的详情里点「沉淀成知识」。",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = InkMuted,
|
||||
)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
OutlinedTextField(
|
||||
value = newTitle,
|
||||
onValueChange = { newTitle = it },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
placeholder = { Text("一句话说清这条知识是什么", color = InkMuted) },
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
colors = TextFieldDefaults.colors(
|
||||
focusedContainerColor = Color.White,
|
||||
unfocusedContainerColor = Color.White,
|
||||
focusedIndicatorColor = Primary,
|
||||
unfocusedIndicatorColor = Line,
|
||||
),
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
OutlinedTextField(
|
||||
value = newBody,
|
||||
onValueChange = { newBody = it },
|
||||
modifier = Modifier.fillMaxWidth().heightIn(min = 90.dp),
|
||||
placeholder = { Text("正文(可空,之后再补)", color = InkMuted) },
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
colors = TextFieldDefaults.colors(
|
||||
focusedContainerColor = Color.White,
|
||||
unfocusedContainerColor = Color.White,
|
||||
focusedIndicatorColor = Primary,
|
||||
unfocusedIndicatorColor = Line,
|
||||
),
|
||||
)
|
||||
KnowledgeComposerDialog(
|
||||
api = api,
|
||||
workspace = workspace,
|
||||
categories = categories,
|
||||
onDismiss = { adding = false },
|
||||
onCreated = { created ->
|
||||
adding = false
|
||||
scope.launch {
|
||||
loadCategories()
|
||||
reload()
|
||||
open = created
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
scope.launch {
|
||||
adding = false
|
||||
runCatching { api.createKnowledge(workspace.id, newTitle.trim(), newBody) }
|
||||
.onSuccess { reload() }
|
||||
.onFailure { fail(it) }
|
||||
}
|
||||
}) { Text("保存草稿") }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = { adding = false }) { Text("取消") } },
|
||||
containerColor = Paper,
|
||||
onFail = { fail(it) },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -257,18 +289,182 @@ private fun sourceLine(doc: KnowledgeDoc): String {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 新建知识:手机上也要能一次写全(标题/类型/分类/标签/正文)。
|
||||
* 用整屏 94% 宽的 Dialog 而不是 AlertDialog —— AlertDialog 的宽度上限装不下一篇备忘录。
|
||||
*/
|
||||
@Composable
|
||||
private fun KnowledgeComposerDialog(
|
||||
api: Api,
|
||||
workspace: Workspace,
|
||||
categories: List<KnowledgeCategory>,
|
||||
onDismiss: () -> Unit,
|
||||
onCreated: (KnowledgeDoc) -> Unit,
|
||||
onFail: (Throwable) -> Unit,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
var title by remember { mutableStateOf("") }
|
||||
var docType by remember { mutableStateOf("note") }
|
||||
var categoryId by remember { mutableStateOf<String?>(null) }
|
||||
var tags by remember { mutableStateOf("") }
|
||||
var body by remember { mutableStateOf("") }
|
||||
var saving by remember { mutableStateOf(false) }
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = onDismiss,
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth(0.94f)
|
||||
.fillMaxHeight(0.88f)
|
||||
.background(Paper, RoundedCornerShape(16.dp))
|
||||
.padding(16.dp),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
"记一条知识",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
GhostButton("取消", onClick = onDismiss)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
PrimaryButton(
|
||||
"保存",
|
||||
enabled = !saving && title.isNotBlank(),
|
||||
onClick = {
|
||||
scope.launch {
|
||||
saving = true
|
||||
runCatching {
|
||||
api.createKnowledge(
|
||||
workspace.id,
|
||||
title.trim(),
|
||||
body,
|
||||
docType,
|
||||
parseTags(tags),
|
||||
categoryId,
|
||||
)
|
||||
}
|
||||
.onSuccess { onCreated(it) }
|
||||
.onFailure { onFail(it) }
|
||||
saving = false
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Column(
|
||||
Modifier.weight(1f).verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = title,
|
||||
onValueChange = { title = it },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
placeholder = { Text("一句话说清这条知识是什么", color = InkMuted) },
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
colors = TextFieldDefaults.colors(
|
||||
focusedContainerColor = Color.White,
|
||||
unfocusedContainerColor = Color.White,
|
||||
focusedIndicatorColor = Primary,
|
||||
unfocusedIndicatorColor = Line,
|
||||
),
|
||||
)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
SectionLabel("类型")
|
||||
Row(
|
||||
modifier = Modifier.horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
docTypes.forEach { (key, label) ->
|
||||
SurfaceChip(
|
||||
text = label,
|
||||
selected = docType == key,
|
||||
onClick = { docType = key },
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(10.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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
if (categories.isEmpty()) {
|
||||
Text(
|
||||
"还没有分类,Web 端可以建三级分类",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = InkMuted,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
}
|
||||
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,
|
||||
),
|
||||
)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
SectionLabel("正文 · Markdown")
|
||||
OutlinedTextField(
|
||||
value = body,
|
||||
onValueChange = { body = it },
|
||||
modifier = Modifier.fillMaxWidth().heightIn(min = 240.dp),
|
||||
placeholder = { Text("背景、做法、结论……写清楚以后自己还能看懂", color = InkMuted) },
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
colors = TextFieldDefaults.colors(
|
||||
focusedContainerColor = Color.White,
|
||||
unfocusedContainerColor = Color.White,
|
||||
focusedIndicatorColor = Primary,
|
||||
unfocusedIndicatorColor = Line,
|
||||
),
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun KnowledgeDetailDialog(
|
||||
api: Api,
|
||||
workspace: Workspace,
|
||||
doc: KnowledgeDoc,
|
||||
categories: List<KnowledgeCategory>,
|
||||
onClose: () -> Unit,
|
||||
onChanged: () -> Unit,
|
||||
onCategoriesChanged: () -> Unit,
|
||||
onFail: (Throwable) -> Unit,
|
||||
onEdit: (EditRequest) -> Unit,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
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) }
|
||||
val actions = when (action) {
|
||||
"draft" -> listOf("submit_review" to "提交审议", "publish" to "发布", "archive" to "归档")
|
||||
"in_review" -> listOf("withdraw" to "撤回审议", "publish" to "发布", "archive" to "归档")
|
||||
@@ -292,15 +488,61 @@ private fun KnowledgeDetailDialog(
|
||||
},
|
||||
text = {
|
||||
Column(
|
||||
Modifier.heightIn(max = 420.dp).verticalScroll(rememberScrollState()),
|
||||
Modifier.heightIn(max = 460.dp).verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
Text(sourceLine(doc), style = MaterialTheme.typography.labelSmall, color = InkMuted)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Text(
|
||||
doc.bodyMd.ifBlank { "(还没有正文,点「补一句」在 Web 端补齐更快)" },
|
||||
doc.bodyMd.ifBlank { "(还没有正文,点「编辑正文」补齐)" },
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = InkSoft,
|
||||
)
|
||||
|
||||
// 已发布的内容不能直接改,分类/标签也跟着锁住,避免"发布后又悄悄换归属"
|
||||
if (action != "published") {
|
||||
Spacer(Modifier.height(14.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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
@@ -312,7 +554,7 @@ private fun KnowledgeDetailDialog(
|
||||
itemId = doc.id,
|
||||
title = doc.title,
|
||||
body = doc.bodyMd,
|
||||
version = doc.version,
|
||||
version = version,
|
||||
titleEnabled = action == "draft",
|
||||
knowledge = true,
|
||||
),
|
||||
@@ -320,6 +562,34 @@ private fun KnowledgeDetailDialog(
|
||||
onClose()
|
||||
},
|
||||
) { Text("编辑正文") }
|
||||
if (action != "published") {
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user