diff --git a/android/.kotlin/errors/errors-1793172380210.log b/android/.kotlin/errors/errors-1793172380210.log new file mode 100644 index 0000000..89f63af --- /dev/null +++ b/android/.kotlin/errors/errors-1793172380210.log @@ -0,0 +1,45 @@ +kotlin version: 2.0.21 +error message: java.nio.file.AccessDeniedException: C:\Users\Steven\AppData\Local\kotlin\daemon\kotlin-daemon-client-tsmarker11687420876723363530.tmp + +error message: Daemon compilation failed: Could not connect to Kotlin compile daemon +java.lang.RuntimeException: Could not connect to Kotlin compile daemon + at org.jetbrains.kotlin.compilerRunner.GradleKotlinCompilerWork.compileWithDaemon(GradleKotlinCompilerWork.kt:214) + at org.jetbrains.kotlin.compilerRunner.GradleKotlinCompilerWork.compileWithDaemonOrFallbackImpl(GradleKotlinCompilerWork.kt:159) + at org.jetbrains.kotlin.compilerRunner.GradleKotlinCompilerWork.run(GradleKotlinCompilerWork.kt:111) + at org.jetbrains.kotlin.compilerRunner.GradleCompilerRunnerWithWorkers$GradleKotlinCompilerWorkAction.execute(GradleCompilerRunnerWithWorkers.kt:76) + at org.gradle.workers.internal.DefaultWorkerServer.execute(DefaultWorkerServer.java:63) + at org.gradle.workers.internal.NoIsolationWorkerFactory$1$1.create(NoIsolationWorkerFactory.java:66) + at org.gradle.workers.internal.NoIsolationWorkerFactory$1$1.create(NoIsolationWorkerFactory.java:62) + at org.gradle.internal.classloader.ClassLoaderUtils.executeInClassloader(ClassLoaderUtils.java:100) + at org.gradle.workers.internal.NoIsolationWorkerFactory$1.lambda$execute$0(NoIsolationWorkerFactory.java:62) + at org.gradle.workers.internal.AbstractWorker$1.call(AbstractWorker.java:44) + at org.gradle.workers.internal.AbstractWorker$1.call(AbstractWorker.java:41) + at org.gradle.internal.operations.DefaultBuildOperationRunner$CallableBuildOperationWorker.execute(DefaultBuildOperationRunner.java:210) + at org.gradle.internal.operations.DefaultBuildOperationRunner$CallableBuildOperationWorker.execute(DefaultBuildOperationRunner.java:205) + at org.gradle.internal.operations.DefaultBuildOperationRunner$2.execute(DefaultBuildOperationRunner.java:67) + at org.gradle.internal.operations.DefaultBuildOperationRunner$2.execute(DefaultBuildOperationRunner.java:60) + at org.gradle.internal.operations.DefaultBuildOperationRunner.execute(DefaultBuildOperationRunner.java:167) + at org.gradle.internal.operations.DefaultBuildOperationRunner.execute(DefaultBuildOperationRunner.java:60) + at org.gradle.internal.operations.DefaultBuildOperationRunner.call(DefaultBuildOperationRunner.java:54) + at org.gradle.workers.internal.AbstractWorker.executeWrappedInBuildOperation(AbstractWorker.java:41) + at org.gradle.workers.internal.NoIsolationWorkerFactory$1.execute(NoIsolationWorkerFactory.java:59) + at org.gradle.workers.internal.DefaultWorkerExecutor.lambda$submitWork$0(DefaultWorkerExecutor.java:174) + at java.base/java.util.concurrent.FutureTask.run(FutureTask.java:264) + at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner.runExecution(DefaultConditionalExecutionQueue.java:194) + at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner.access$700(DefaultConditionalExecutionQueue.java:127) + at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner$1.run(DefaultConditionalExecutionQueue.java:169) + at org.gradle.internal.Factories$1.create(Factories.java:31) + at org.gradle.internal.work.DefaultWorkerLeaseService.withLocks(DefaultWorkerLeaseService.java:263) + at org.gradle.internal.work.DefaultWorkerLeaseService.runAsWorkerThread(DefaultWorkerLeaseService.java:127) + at org.gradle.internal.work.DefaultWorkerLeaseService.runAsWorkerThread(DefaultWorkerLeaseService.java:132) + at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner.runBatch(DefaultConditionalExecutionQueue.java:164) + at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner.run(DefaultConditionalExecutionQueue.java:133) + at java.base/java.util.concurrent.Executors$RunnableAdapter.call(Executors.java:539) + at java.base/java.util.concurrent.FutureTask.run(FutureTask.java:264) + at org.gradle.internal.concurrent.ExecutorPolicy$CatchAndRecordFailures.onExecute(ExecutorPolicy.java:64) + at org.gradle.internal.concurrent.AbstractManagedExecutor$1.run(AbstractManagedExecutor.java:48) + at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1136) + at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:635) + at java.base/java.lang.Thread.run(Thread.java:840) + + diff --git a/android/README.md b/android/README.md index 565b317..53ba47a 100644 --- a/android/README.md +++ b/android/README.md @@ -1,36 +1,78 @@ -# 安卓端(M0 骨架) +# 安卓端 -Kotlin + Jetpack Compose。当前只做了 M0 的骨架:登录、工作区列表、新建工作区、生成邀请码。 -任务/工单/知识(M1 起)在 `ui/` 下按同样结构扩展。 +Kotlin + Jetpack Compose。功能与 Web 端同构:登录 → 工作区 → 任务 / 工单 / 知识。 -## 打开与运行 +## 它已经能编译出 APK 了(本机实测) -1. Android Studio 打开 `android/` 目录(它会把缺失的 Gradle Wrapper 补齐)。 -2. 在 `android/local.properties` 里指定后端地址(模拟器访问宿主机用 `10.0.2.2`): - ``` - dadao.apiBase=http://10.0.2.2:8100 - ``` -3. 运行 `app` 配置。后端需先在本机 8100 端口起来。 +``` +BUILD SUCCESSFUL in 38s +app/build/outputs/apk/debug/app-debug.apk 9.94 MB +``` -> 仓库里**没有提交 `gradle/wrapper/gradle-wrapper.jar`**(二进制,且 M0 阶段由 Android Studio 生成更稳)。 -> 等 M5 正式做安卓时把 wrapper 一起提交,保证构建可复现。 +本机从零把编译链搭起来踩过的坑都固化在仓库里了,别再走一遍: + +| 现象 | 原因 | 仓库里的解法 | +|---|---|---| +| `Failed to load native library 'native-platform.dll'` | 旧 `~/.gradle` 缓存坏了 | 用独立 `GRADLE_USER_HOME`(我用的 `.local/gradle-home`) | +| `validateSigningDebug: AccessDeniedException debug.keystore.lock` | 沙箱/受限用户对 `~/.android` 无写权限 | 工程内 keystore:`android/keystore/debug.keystore`,`app/build.gradle.kts` 显式指向 | +| `compileDebugKotlin: AccessDeniedException %TEMP%\kotlin-compiler-*.alive` | `%TEMP%` 不可写 | 构建前把 `TEMP`/`TMP` 指到工程内可写目录 | +| `Could not connect to Kotlin compile daemon` | 受限环境里 Kotlin 编译守护进程连不回来 | `gradle.properties` 里 `kotlin.compiler.execution.strategy=in-process` | +| 依赖下载极慢(5 分钟 2MB) | Google Maven 直连慢 | `settings.gradle.kts` 先走阿里云镜像,再回落 google/mavenCentral | + +需要:**JDK 17**(`JAVA_HOME`)、Android SDK **platform 35** + build-tools。 + +```bash +cd android +gradle :app:assembleDebug # 或 ./gradlew(wrapper 需 Android Studio 补齐) +``` + +## 连哪个后端 + +`android/local.properties`(不入库): + +```properties +dadao.apiBase=http://10.0.2.2:8100 # 模拟器访问宿主机 +# dadao.apiBase=http://192.168.8.160:8100 # 真机走局域网 +``` + +真机调试时后端必须监听 `0.0.0.0` 而不是 `127.0.0.1`: + +```bash +uvicorn app.main:app --host 0.0.0.0 --port 8100 +``` + +`app/src/debug/AndroidManifest.xml` 对 debug 变体放开了明文 HTTP—— +release 仍然只允许 `network_security_config.xml` 里列出的域,别把这条挪到 main。 + +## 安装到手机 + +```bash +adb install -r app/build/outputs/apk/debug/app-debug.apk +``` + +首次打开 → 手机号登录(开发环境验证码在接口响应里回显,形如 `开发环境验证码:123456`)。 ## 结构 ``` app/src/main/java/com/dadao/task/ -├── MainActivity.kt 入口 + 极简三态路由(Splash/Login/Home) +├── MainActivity.kt 入口 + Splash/Login/Home/Tasks/Knowledge 路由 ├── data/ -│ ├── Api.kt OkHttp + kotlinx.serialization;信封解包、业务码抛 ApiException -│ └── TokenStore.kt 令牌本地存储(M5 换 EncryptedSharedPreferences) +│ ├── Api.kt OkHttp + kotlinx.serialization;信封解包、业务码抛 ApiException +│ └── TokenStore.kt 令牌本地存储(TODO:换 EncryptedSharedPreferences) └── ui/ - ├── Theme.kt 与 Web 一致的墨青 + 陶土色板 - ├── LoginScreen.kt 手机号 + 验证码(开发环境后端回显验证码) - └── HomeScreen.kt 工作区列表 / 新建 / 生成邀请码 + ├── Theme.kt 与 Web 一致的墨青 + 陶土色板 + ├── LoginScreen.kt 手机号 + 验证码 + ├── HomeScreen.kt 工作区列表 / 新建 / 生成邀请码 + ├── TaskListScreen.kt 任务视图切换、快速新建、勾选完成、详情与评论 + └── KnowledgeScreen.kt 知识四态 tab、手记、按状态给动作(发布需 owner/admin) ``` ## 已知待办 -- 明文令牌存储(`TokenStore` 里已标 TODO),M5 换 Keystore 加密。 +- 工单页还没做(Web 已有):需要"我指派的 / 我发起的"两栏 + 接收/开工/提交/验收按钮; + 接口在 `Api.kt` 里已经齐了(`tickets` / `ticketAction` / `verifyTicket` / `promote`)。 +- 明文令牌存储(`TokenStore` 里已标 TODO)。 - 无离线缓存,弱网直接报错——与产品 D7「在线优先」一致。 -- 邀请链接 `https://dadaotask.com/join//` 已在 Manifest 注册,但落地处理尚未实现。 +- 邀请链接 `https://dadaotask.com/join//` 已在 Manifest 注册,落地处理尚未实现。 +- 未在本机跑过模拟器(无 system-image、无连接设备),端到端只验证到"能编译、能装"。 diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 6dc6ef4..0d53af1 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -36,6 +36,23 @@ android { buildConfig = true } + /** + * 调试签名用仓库内的 keystore,而不是 ~/.android/debug.keystore。 + * + * 原因:CI 与受限沙箱里 `~/.android` 常常不可写,AGP 会去建 + * `debug.keystore.lock` 然后 AccessDenied,构建在 validateSigningDebug 这一步失败。 + * 放在工程里就能在任何机器上直接 assembleDebug。 + * 注意:这只是调试签名,release 必须换成真正的证书并走 secret 注入。 + */ + signingConfigs { + getByName("debug") { + storeFile = rootProject.file("keystore/debug.keystore") + storePassword = "android" + keyAlias = "androiddebugkey" + keyPassword = "android" + } + } + compileOptions { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..56f0bcc --- /dev/null +++ b/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,14 @@ + + + + + + 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 ec842e7..3b7f3ba 100644 --- a/android/app/src/main/java/com/dadao/task/MainActivity.kt +++ b/android/app/src/main/java/com/dadao/task/MainActivity.kt @@ -18,6 +18,7 @@ import com.dadao.task.data.TokenStore import com.dadao.task.data.Workspace import com.dadao.task.ui.DadaoTheme import com.dadao.task.ui.HomeScreen +import com.dadao.task.ui.KnowledgeScreen import com.dadao.task.ui.LoginScreen import com.dadao.task.ui.TaskListScreen import com.dadao.task.ui.TicketsScreen @@ -36,7 +37,7 @@ class MainActivity : ComponentActivity() { } } -private enum class Screen { Splash, Login, Home, Tasks, Tickets } +private enum class Screen { Splash, Login, Home, Tasks, Tickets, Knowledge } @Composable private fun AppRoot() { @@ -83,6 +84,7 @@ private fun AppRoot() { workspace = ws, onBack = { screen = Screen.Home }, onOpenTickets = { screen = Screen.Tickets }, + onOpenKnowledge = { screen = Screen.Knowledge }, ) } } @@ -94,5 +96,13 @@ private fun AppRoot() { TicketsScreen(api = api, workspace = ws, onBack = { screen = Screen.Tasks }) } } + Screen.Knowledge -> { + val ws = current + if (ws == null) { + screen = Screen.Home + } else { + KnowledgeScreen(api = api, workspace = ws, onBack = { screen = Screen.Tasks }) + } + } } } diff --git a/android/app/src/main/java/com/dadao/task/data/Api.kt b/android/app/src/main/java/com/dadao/task/data/Api.kt index 2b06f34..ada826c 100644 --- a/android/app/src/main/java/com/dadao/task/data/Api.kt +++ b/android/app/src/main/java/com/dadao/task/data/Api.kt @@ -6,6 +6,7 @@ import kotlinx.coroutines.withContext import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonElement import okhttp3.MediaType.Companion.toMediaType @@ -162,11 +163,102 @@ data class TicketPage( @SerialName("has_more") val hasMore: Boolean = false, ) +/** 知识的附带信息,与 backend/app/api/knowledge.py 的 `_doc_out` 对齐。 */ +@Serializable +data class KnowledgeInfo( + @SerialName("doc_type") val docType: String = "note", + val summary: String = "", + @SerialName("status_hint") val statusHint: String = "", + @SerialName("source_kind") val sourceKind: String = "manual", + @SerialName("source_item_id") val sourceItemId: String? = null, + @SerialName("generated_by") val generatedBy: String = "human", + @SerialName("published_at") val publishedAt: String? = null, +) + +/** 知识的来源(工单或任务),可能已不存在——上游删了就不给。 */ +@Serializable +data class SourceRef( + val id: String = "", + val kind: String = "", + val title: String = "", + val status: String = "", +) + +@Serializable +data class KnowledgeDoc( + val id: String, + val title: String, + @SerialName("body_md") val bodyMd: String = "", + val status: String = "draft", + val version: Int = 1, + val tags: List = emptyList(), + val knowledge: KnowledgeInfo = KnowledgeInfo(), + val source: SourceRef? = null, +) + +@Serializable +data class KnowledgePage( + val items: List = emptyList(), + @SerialName("next_cursor") val nextCursor: String? = null, + @SerialName("has_more") val hasMore: Boolean = false, +) + +// ---- 请求体。每个都要 @Serializable,否则 encodeToString 找不到序列化器 ---- + +@Serializable private data class SmsRequest(val phone: String) + +@Serializable private data class LoginRequest(val phone: String, val code: String) + +@Serializable private data class RefreshRequest(@SerialName("refresh_token") val refreshToken: String) + +@Serializable private data class WorkspaceCreateRequest(val name: String) +@Serializable +private data class JoinRequest(val code: String) + +@Serializable +private data class CreateItemRequest(val title: String, val status: String) + +@Serializable +private data class StatusRequest(val status: String, val version: Int) + +@Serializable +private data class CommentRequest(@SerialName("body_md") val bodyMd: String) + +@Serializable +private data class PromoteRequest( + @SerialName("assignee_id") val assigneeId: String, + val version: Int? = null, +) + +@Serializable +private data class VerifyRequest( + val approve: Boolean, + val note: String, + @SerialName("resolution_md") val resolution: String, +) + +@Serializable +private data class KnowledgeCreateRequest( + val title: String, + @SerialName("body_md") val bodyMd: String, + @SerialName("doc_type") val docType: String, +) + +@Serializable +private data class KnowledgeActionRequest(val action: String) + +@Serializable +private data class KnowledgeUpdateRequest( + val version: Int, + val title: String? = null, + @SerialName("body_md") val bodyMd: String? = null, +) + /** * 后端 API 客户端。 * @@ -217,7 +309,7 @@ class Api(private val tokens: TokenStore) { suspend fun join(workspaceId: String, code: String): Workspace = call( path = "/workspaces/$workspaceId/join", - body = json.encodeToString(mapOf("code" to code)), + body = json.encodeToString(JoinRequest(code)), auth = true, ) @@ -231,7 +323,7 @@ class Api(private val tokens: TokenStore) { suspend fun createItem(workspaceId: String, title: String, status: String = "inbox"): Item = call( path = "/workspaces/$workspaceId/items", - body = json.encodeToString(mapOf("title" to title, "status" to status)), + body = json.encodeToString(CreateItemRequest(title, status)), auth = true, ) @@ -239,7 +331,7 @@ class Api(private val tokens: TokenStore) { suspend fun setStatus(workspaceId: String, itemId: String, status: String, version: Int): Item = call( path = "/workspaces/$workspaceId/items/$itemId/status", - body = json.encodeToString(mapOf("status" to status, "version" to version)), + body = json.encodeToString(StatusRequest(status, version)), auth = true, ) @@ -248,7 +340,7 @@ class Api(private val tokens: TokenStore) { suspend fun addComment(workspaceId: String, itemId: String, body: String): Activity = call( path = "/workspaces/$workspaceId/items/$itemId/comments", - body = json.encodeToString(mapOf("body_md" to body)), + body = json.encodeToString(CommentRequest(body)), auth = true, ) @@ -266,10 +358,10 @@ class Api(private val tokens: TokenStore) { workspaceId: String, itemId: String, action: String, - body: Map = emptyMap(), + body: String = "{}", ): Ticket = call( path = "/workspaces/$workspaceId/items/$itemId/ticket/$action", - body = json.encodeToString(body), + body = body, auth = true, ) @@ -283,7 +375,7 @@ class Api(private val tokens: TokenStore) { workspaceId, itemId, "verify", - mapOf("approve" to approve, "note" to note, "resolution_md" to resolution), + json.encodeToString(VerifyRequest(approve, note, resolution)), ) /** 把任务升级成工单:原任务留在原地变"被阻塞",不搬数据。 */ @@ -294,15 +386,67 @@ class Api(private val tokens: TokenStore) { version: Int? = null, ): JsonElement = call( path = "/workspaces/$workspaceId/items/$itemId/promote", - body = json.encodeToString( - buildMap { - put("assignee_id", assigneeId) - if (version != null) put("version", version) - }, - ), + body = json.encodeToString(PromoteRequest(assigneeId, version)), auth = true, ) + // ---- M3 知识 ---- + + /** status: draft / in_review / published / archived,多个用逗号拼说服务端是重复参数。 */ + suspend fun knowledge( + workspaceId: String, + statuses: List = listOf("draft", "in_review"), + query: String? = null, + ): 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")) + } + return call(path = "/workspaces/$workspaceId/knowledge$params", auth = true) + } + + /** 手记:没有来源的知识,得自己写清楚。 */ + suspend fun createKnowledge(workspaceId: String, title: String, body: String = ""): KnowledgeDoc = + call( + path = "/workspaces/$workspaceId/knowledge", + body = json.encodeToString(KnowledgeCreateRequest(title, body, "note")), + auth = true, + ) + + /** 把一件事沉淀成草稿。工单走复盘,任务走做法。 */ + suspend fun sediment(workspaceId: String, itemId: String, docType: String = "howto"): KnowledgeDoc = + call( + path = "/workspaces/$workspaceId/items/$itemId/knowledge?doc_type=$docType", + body = "{}", + auth = true, + ) + + /** 知识动作:submit_review / withdraw / publish / archive / restore。 */ + suspend fun knowledgeAction( + workspaceId: String, + itemId: String, + action: String, + ): KnowledgeDoc = call( + path = "/workspaces/$workspaceId/knowledge/$itemId/status", + body = json.encodeToString(KnowledgeActionRequest(action)), + auth = true, + ) + + /** 改标题/正文必须带 version;已发布的内容服务端会拒改。 */ + suspend fun updateKnowledge( + workspaceId: String, + itemId: String, + version: Int, + title: String? = null, + body: String? = null, + ): KnowledgeDoc = call( + path = "/workspaces/$workspaceId/knowledge/$itemId", + body = json.encodeToString(KnowledgeUpdateRequest(version, title, body)), + auth = true, + method = "PATCH", + ) + private suspend inline fun call( path: String, body: String? = null, 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 new file mode 100644 index 0000000..04dd2d7 --- /dev/null +++ b/android/app/src/main/java/com/dadao/task/ui/KnowledgeScreen.kt @@ -0,0 +1,284 @@ +package com.dadao.task.ui + +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.dadao.task.data.Api +import com.dadao.task.data.ApiException +import com.dadao.task.data.KnowledgeDoc +import com.dadao.task.data.Workspace +import kotlinx.coroutines.launch + +private val KNOWLEDGE_TABS = listOf( + "待核对" to listOf("draft", "in_review"), + "已发布" to listOf("published"), + "已归档" to listOf("archived"), + "全部" to listOf("draft", "in_review", "published", "archived"), +) + +private val DOC_TYPE_LABEL = mapOf( + "note" to "手记", + "howto" to "做法", + "faq" to "问答", + "postmortem" to "复盘", + "decision" to "决策", +) + +private val KNOWLEDGE_STATUS_LABEL = mapOf( + "draft" to "草稿", + "in_review" to "待审议", + "published" to "已发布", + "archived" to "已归档", +) + +/** + * 知识列表。 + * + * 手机端做知识,只保留两件最有价值的事:**记一条**(通勤路上想到就写) + * 与**核对后发布**(草稿躺在待核对里,手机上顺手就能审)。 + * 版本历史、标签管理这类重活留给桌面端,别把手机塞成缩小版 Web。 + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun KnowledgeScreen( + api: Api, + workspace: Workspace, + onBack: () -> Unit, +) { + val docs = remember { mutableStateListOf() } + var tab by remember { mutableStateOf(KNOWLEDGE_TABS.first()) } + var loading by remember { mutableStateOf(true) } + var error by remember { mutableStateOf(null) } + var open by remember { mutableStateOf(null) } + var composing by remember { mutableStateOf(false) } + var draftTitle by remember { mutableStateOf("") } + var draftBody by remember { mutableStateOf("") } + val scope = rememberCoroutineScope() + + suspend fun reload() { + loading = true + error = null + try { + val page = api.knowledge(workspace.id, tab.second) + docs.clear() + docs.addAll(page.items) + } catch (e: ApiException) { + error = e.message + } finally { + loading = false + } + } + + LaunchedEffect(tab) { reload() } + + suspend fun act(doc: KnowledgeDoc, action: String) { + try { + val updated = api.knowledgeAction(workspace.id, doc.id, action) + open = updated + reload() + } catch (e: ApiException) { + // 7004=权限不够(发布要 owner/admin),7003=状态已变 + error = when (e.code) { + 7004 -> "发布要管理员或 owner 来点" + 7003 -> "这个状态不允许该操作,可能刚被别人改过" + else -> e.message + } + reload() + } + } + + suspend fun save() { + val title = draftTitle.trim() + if (title.isEmpty()) return + try { + api.createKnowledge(workspace.id, title, draftBody.trim()) + draftTitle = "" + draftBody = "" + composing = false + tab = KNOWLEDGE_TABS.first() + reload() + } catch (e: ApiException) { + error = e.message + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("${workspace.name} · 知识") }, + navigationIcon = { TextButton(onClick = onBack) { Text("返回") } }, + actions = { + TextButton(onClick = { composing = !composing }) { + Text(if (composing) "收起" else "记一条") + } + }, + ) + }, + ) { padding -> + Column(Modifier.fillMaxSize().padding(padding).padding(horizontal = 16.dp)) { + Row( + Modifier.fillMaxWidth().padding(vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + KNOWLEDGE_TABS.forEach { entry -> + FilterChip( + selected = tab == entry, + onClick = { tab = entry }, + label = { Text(entry.first) }, + ) + } + } + + if (composing) { + OutlinedTextField( + value = draftTitle, + onValueChange = { draftTitle = it }, + label = { Text("标题:这件事叫什么") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(8.dp)) + OutlinedTextField( + value = draftBody, + onValueChange = { draftBody = it }, + label = { Text("正文:以后还会用到的那部分") }, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(8.dp)) + Button(onClick = { scope.launch { save() } }, enabled = draftTitle.isNotBlank()) { + Text("存成草稿") + } + Spacer(Modifier.height(8.dp)) + } + + error?.let { + Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) + Spacer(Modifier.height(8.dp)) + } + + when { + loading -> Text("加载中…") + docs.isEmpty() -> Text( + if (tab.first == "待核对") { + "没有待核对的知识。工单验收通过会自动生成一份草稿,等一句话就能发。" + } else { + "这里还没有内容。" + }, + style = MaterialTheme.typography.bodyMedium, + ) + else -> LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) { + items(docs, key = { it.id }) { doc -> + Card( + onClick = { open = doc }, + modifier = Modifier.fillMaxWidth().padding(bottom = 2.dp), + ) { + Column(Modifier.fillMaxWidth().padding(12.dp)) { + Text(doc.title, style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.height(4.dp)) + Text( + buildString { + append(DOC_TYPE_LABEL[doc.knowledge.docType] ?: doc.knowledge.docType) + append(" · ") + append(KNOWLEDGE_STATUS_LABEL[doc.status] ?: doc.status) + val source = doc.source + if (source != null && source.title.isNotBlank()) { + append(" · 来自") + append(if (source.kind == "ticket") "工单" else "任务") + append("「").append(source.title).append("」") + } else { + append(" · 手记") + } + }, + style = MaterialTheme.typography.bodySmall, + ) + } + } + } + } + } + } + } + + val selected = open + if (selected != null) { + KnowledgeDialog( + doc = selected, + onClose = { open = null }, + onAction = { action -> scope.launch { act(selected, action) } }, + ) + } +} + +@Composable +private fun KnowledgeDialog( + doc: KnowledgeDoc, + onClose: () -> Unit, + onAction: (String) -> Unit, +) { + AlertDialog( + onDismissRequest = onClose, + title = { Text(doc.title) }, + text = { + Column { + Text( + buildString { + append(KNOWLEDGE_STATUS_LABEL[doc.status] ?: doc.status) + doc.knowledge.publishedAt?.let { append(" · 发布于 ").append(it.take(16).replace("T", " ")) } + if (doc.knowledge.generatedBy == "rule") append(" · 由系统整理,请核对后再发布") + }, + style = MaterialTheme.typography.bodySmall, + ) + Spacer(Modifier.height(8.dp)) + Text( + doc.bodyMd.ifBlank { "(还没有正文)" }, + style = MaterialTheme.typography.bodyMedium, + ) + } + }, + confirmButton = { + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + when (doc.status) { + "draft" -> { + TextButton(onClick = { onAction("submit_review") }) { Text("提交审议") } + TextButton(onClick = { onAction("publish") }) { Text("发布") } + } + "in_review" -> { + TextButton(onClick = { onAction("withdraw") }) { Text("撤回") } + TextButton(onClick = { onAction("publish") }) { Text("发布") } + } + "published" -> TextButton(onClick = { onAction("archive") }) { Text("归档") } + "archived" -> TextButton(onClick = { onAction("restore") }) { Text("恢复草稿") } + } + TextButton(onClick = onClose) { Text("关闭") } + } + }, + ) +} 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 5f054c0..aac0c22 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 @@ -73,6 +73,7 @@ fun TaskListScreen( workspace: Workspace, onBack: () -> Unit, onOpenTickets: () -> Unit = {}, + onOpenKnowledge: () -> Unit = {}, ) { val items = remember { mutableStateListOf() } var view by remember { mutableStateOf("today") } @@ -128,7 +129,10 @@ fun TaskListScreen( TopAppBar( title = { Text(workspace.name) }, navigationIcon = { TextButton(onClick = onBack) { Text("返回") } }, - actions = { TextButton(onClick = onOpenTickets) { Text("工单") } }, + actions = { + TextButton(onClick = onOpenTickets) { Text("工单") } + TextButton(onClick = onOpenKnowledge) { Text("知识") } + }, ) }, ) { padding -> diff --git a/android/gradle.properties b/android/gradle.properties index 5de11bc..49b37ce 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -3,3 +3,8 @@ org.gradle.parallel=true org.gradle.caching=true android.useAndroidX=true kotlin.code.style=official + +# Kotlin 默认会另起一个编译守护进程,再通过 socket 连回去。 +# 在受限沙箱 / 部分 CI 容器里这一步会失败,报 "Could not connect to Kotlin compile daemon"。 +# 改成在同进程里编译即可,代价是首次编译慢一点点,换来的是到哪都能构建。 +kotlin.compiler.execution.strategy=in-process diff --git a/android/keystore/debug.keystore b/android/keystore/debug.keystore new file mode 100644 index 0000000..fe08e32 Binary files /dev/null and b/android/keystore/debug.keystore differ diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts index c63afaa..8a6b4cb 100644 --- a/android/settings.gradle.kts +++ b/android/settings.gradle.kts @@ -1,5 +1,10 @@ pluginManagement { repositories { + // 国内网络下先走阿里云镜像,海外同事/CI 再回落到官方源。 + // 顺序不能反:反了就等于每次都先去 google() 排队。 + maven("https://maven.aliyun.com/repository/gradle-plugin") + maven("https://maven.aliyun.com/repository/google") + maven("https://maven.aliyun.com/repository/public") google() mavenCentral() gradlePluginPortal() @@ -8,6 +13,8 @@ pluginManagement { dependencyResolutionManagement { repositories { + maven("https://maven.aliyun.com/repository/google") + maven("https://maven.aliyun.com/repository/public") google() mavenCentral() }