feat(knowledge): 知识改成备忘录形态,一次写全 + 三级分类,抽屉加宽
CI / backend (push) Canceled after 0s

知识以前只能"填个标题就完成",正文、标签、归类都要点进去再补。现在新建
就是一张完整的备忘录:标题、类型、分类、标签、正文一次写完,存完直接打开
详情继续编辑;知识页左侧多了工作区三级分类树,可按分类子树 / 未分类 / 标签
筛选。凡是填信息的抽屉都加宽了,还能一键展开整屏。

后端
- 新增 `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:
Steven
2026-10-30 03:51:43 +08:00
parent 91b3dde670
commit ed31af3f46
18 changed files with 1932 additions and 164 deletions
@@ -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 {