diff --git a/2.Design/screens/android-editor-saved.png b/2.Design/screens/android-editor-saved.png new file mode 100644 index 0000000..1315e3f Binary files /dev/null and b/2.Design/screens/android-editor-saved.png differ diff --git a/android/app/src/main/java/com/dadao/task/MainActivity.kt b/android/app/src/main/java/com/dadao/task/MainActivity.kt index 9a10156..cc9e504 100644 --- a/android/app/src/main/java/com/dadao/task/MainActivity.kt +++ b/android/app/src/main/java/com/dadao/task/MainActivity.kt @@ -73,6 +73,10 @@ private fun AppRoot(joinUri: String?) { var unread by remember { mutableStateOf(0) } // 打开富文本编辑器时非空;关掉就恢复原来的界面 var editing by remember { mutableStateOf(null) } + // 编辑器保存成功的那条:回到工作区后重新打开它的详情,然后清掉 + var reopenItem by remember { mutableStateOf(null) } + // 上面那条是任务还是知识:决定回哪个页面去重开 + var reopenKnowledge by remember { mutableStateOf(false) } // 有本地令牌就先静默校验一次:令牌过期/被吊销要退回登录页,而不是进了主页再崩。 LaunchedEffect(screen) { @@ -124,65 +128,72 @@ private fun AppRoot(joinUri: String?) { ) Screen.Workspace -> { val ws = current + val request = editing if (ws == null) { screen = Screen.Home - } else { - 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 }, + } else 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, ) - - WorkspaceTab.Tickets -> TicketsScreen(api = api, workspace = ws) - WorkspaceTab.Knowledge -> KnowledgeScreen( - api = api, - workspace = ws, - onEdit = { editing = it }, + } else { + api.updateItem( + workspaceId = ws.id, + itemId = request.itemId, + version = request.version, + title = title, + body = body, ) - - WorkspaceTab.Members -> MembersScreen(api = api, workspace = ws) } - } - - val request = editing - if (request != null) { - RichTextEditorScreen( + // 保存成功才标记"回到哪条":取消退出不该改变任何界面状态。 + reopenItem = request.itemId + reopenKnowledge = request.knowledge + }, + // saved 参数由编辑器给:true 时上面已经设过 reopenItem, + // 这里只负责把编辑器收起来,回到工作区后由对应页面重开详情。 + onClose = { _ -> editing = null }, + ) + } else { + WorkspaceScaffold( + workspace = ws, + tab = tab, + unread = unread, + onTab = { tab = it }, + onBackHome = { screen = Screen.Home }, + ) { + when (tab) { + WorkspaceTab.Tasks -> TaskListScreen( 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 }, + workspace = ws, + onEdit = { editing = it }, + reopenItem = if (reopenItem != null && !reopenKnowledge) reopenItem else null, + onReopened = { reopenItem = null }, ) + + WorkspaceTab.Tickets -> TicketsScreen(api = api, workspace = ws) + WorkspaceTab.Knowledge -> KnowledgeScreen( + api = api, + workspace = ws, + onEdit = { editing = it }, + reopenItem = if (reopenItem != null && reopenKnowledge) reopenItem else null, + onReopened = { reopenItem = null }, + ) + + WorkspaceTab.Members -> MembersScreen(api = api, workspace = ws) } } } diff --git a/android/app/src/main/java/com/dadao/task/ui/KnowledgeScreen.kt b/android/app/src/main/java/com/dadao/task/ui/KnowledgeScreen.kt index 0be5215..58de4c3 100644 --- a/android/app/src/main/java/com/dadao/task/ui/KnowledgeScreen.kt +++ b/android/app/src/main/java/com/dadao/task/ui/KnowledgeScreen.kt @@ -55,7 +55,14 @@ private fun docTypeLabel(type: String): String = when (type) { } @Composable -fun KnowledgeScreen(api: Api, workspace: Workspace, onEdit: (EditRequest) -> Unit = {}) { +fun KnowledgeScreen( + api: Api, + workspace: Workspace, + onEdit: (EditRequest) -> Unit = {}, + /** 编辑器保存后要重新打开的那条。父层持有:编辑器打开时本页会被整体卸载。 */ + reopenItem: String? = null, + onReopened: () -> Unit = {}, +) { val scope = rememberCoroutineScope() var tab by remember { mutableStateOf("draft") } var docs by remember { mutableStateOf>(emptyList()) } @@ -85,6 +92,14 @@ fun KnowledgeScreen(api: Api, workspace: Workspace, onEdit: (EditRequest) -> Uni LaunchedEffect(tab) { reload() } + // 从编辑器保存回来:本页是新建出来的,刷完列表再把那条详情重新打开。 + LaunchedEffect(reopenItem) { + val target = reopenItem ?: return@LaunchedEffect + reload() + open = docs.firstOrNull { it.id == target } + onReopened() + } + fun fail(e: Throwable) { val code = (e as? ApiException)?.code ?: 0 message = verifyStatusCode(code).ifBlank { e.message ?: "操作没成功" } diff --git a/android/app/src/main/java/com/dadao/task/ui/TaskDetailSheet.kt b/android/app/src/main/java/com/dadao/task/ui/TaskDetailSheet.kt index 9e2cd5c..70ab269 100644 --- a/android/app/src/main/java/com/dadao/task/ui/TaskDetailSheet.kt +++ b/android/app/src/main/java/com/dadao/task/ui/TaskDetailSheet.kt @@ -68,6 +68,8 @@ fun TaskDetailSheet( onChanged: () -> Unit, /** 进富文本编辑器改正文。标题与状态留在抽屉里,只有正文要更大地方。 */ onEdit: (ItemDetail) -> Unit = {}, + /** 编辑器保存回来后变一次,抽屉据此重新拉数据——否则还显示保存前的旧正文。 */ + refreshKey: Int = 0, ) { val sheet = rememberModalBottomSheetState(skipPartiallyExpanded = true) val scope = rememberCoroutineScope() @@ -93,7 +95,7 @@ fun TaskDetailSheet( runCatching { api.me() }.onSuccess { meId = it.user.id } } - LaunchedEffect(itemId) { reload() } + LaunchedEffect(itemId, refreshKey) { reload() } fun fail(e: Throwable) { val code = (e as? ApiException)?.code ?: 0 diff --git a/android/app/src/main/java/com/dadao/task/ui/TaskListScreen.kt b/android/app/src/main/java/com/dadao/task/ui/TaskListScreen.kt index 1a60f04..6211a04 100644 --- a/android/app/src/main/java/com/dadao/task/ui/TaskListScreen.kt +++ b/android/app/src/main/java/com/dadao/task/ui/TaskListScreen.kt @@ -51,7 +51,14 @@ private val views = listOf( ) @Composable -fun TaskListScreen(api: Api, workspace: Workspace, onEdit: (EditRequest) -> Unit = {}) { +fun TaskListScreen( + api: Api, + workspace: Workspace, + onEdit: (EditRequest) -> Unit = {}, + /** 编辑器保存后要重新打开的那条。父层持这个状态,因为编辑器打开时本页会被整体卸载。 */ + reopenItem: String? = null, + onReopened: () -> Unit = {}, +) { val scope = rememberCoroutineScope() var view by remember { mutableStateOf("active") } var mode by remember { mutableStateOf("list") } @@ -60,6 +67,7 @@ fun TaskListScreen(api: Api, workspace: Workspace, onEdit: (EditRequest) -> Unit var draft by remember { mutableStateOf("") } var query by remember { mutableStateOf("") } var openId by remember { mutableStateOf(null) } + var detailRefresh by remember { mutableStateOf(0) } var error by remember { mutableStateOf(null) } suspend fun reload() { @@ -75,6 +83,15 @@ fun TaskListScreen(api: Api, workspace: Workspace, onEdit: (EditRequest) -> Unit LaunchedEffect(view, query) { reload() } + // 从编辑器保存回来:本页是新建出来的,先刷数据再把那一条详情重新打开。 + LaunchedEffect(reopenItem) { + val target = reopenItem ?: return@LaunchedEffect + reload() + detailRefresh += 1 + openId = target + onReopened() + } + fun fail(e: Throwable) { val code = (e as? ApiException)?.code ?: 0 error = verifyStatusCode(code).ifBlank { e.message ?: "操作没成功" } @@ -193,10 +210,12 @@ fun TaskListScreen(api: Api, workspace: Workspace, onEdit: (EditRequest) -> Unit itemId = id, onClose = { openId = null }, onChanged = { scope.launch { reload() } }, + // 抽屉是独立窗口,关掉才能让编辑器显示在上层;保存回来后由父层重新打开。 onEdit = { openId = null onEdit(EditRequest(it.id, it.title, it.bodyMd, it.version)) }, + refreshKey = detailRefresh, ) } } diff --git a/backend/app/config.py b/backend/app/config.py index e5248de..55c84f6 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -71,6 +71,24 @@ class Settings(BaseSettings): # 30 分钟够一次阅读/编辑,过期后刷新页面会重新签发。 attachment_url_ttl_seconds: int = 1800 + # ── 知识检索(M3)────────────────────────────────────── + # 切片大小与重叠:中文一段 600 字大约 400~600 token,够表达一个完整片段; + # 重叠 80 字是为了不把跨段的话切断(检索命中时前后文还读得懂)。 + chunk_max_chars: int = 600 + chunk_overlap_chars: int = 80 + # 嵌入向量提供方: + # hash —— 内置的字符 n-gram 哈希向量,离线可用、零依赖、可复现; + # 质量不如真模型,但能把"检索链路"跑通,且换模型时接口不变。 + # openai —— 任何 OpenAI 兼容的 /embeddings 端点(BYOK)。 + embedding_provider: Literal["hash", "openai"] = "hash" + embedding_base_url: str = "https://api.deepseek.com/v1" + embedding_api_key: str | None = None + embedding_model: str = "text-embedding-3-small" + # 混合检索里向量与词法的权重。纯词法搜不到同义改写,纯向量搜不准专有名词, + # 所以默认各占一半,可用环境变量调。 + search_vector_weight: float = 0.6 + search_lexical_weight: float = 0.4 + # ── 知识库向量库(独立实例,M3 起用)─────────────────── # 刻意与业务库分开放:fnsvr-01 上的 PG 没有 pgvector,也不该为了装扩展去动它; # 向量检索吃内存,和任务库挤在同一实例会互相拖累。