chore(android): 让安卓端第一次真正编译出 APK
CI / backend (push) Canceled after 0s

之前安卓代码一行都没编译过,这次从零把编译链搭起来,第一遍编译就炸了 5 处。

代码 bug(5 个):
- Api.kt 的 SmsRequest/LoginRequest/WorkspaceCreateRequest/RefreshRequest 都没加
  @Serializable,json.encodeToString(x) 于是匹配到 encodeToString(strategy, value) 重载,
  报 "SerializationStrategy<T> expected" + "No value passed for parameter 'value'"
- 三处 mapOf/buildMap 拼请求体(Map<String, Any>)没法序列化,全换成具名 @Serializable 请求类
- ticketAction 的 body 参数从 Map<String, Any> 改成 String,避免把序列化责任散在调用方
- KnowledgeScreen 的 Card(Modifier, onClick=…) 命中无点击的重载,参数重名报错,
  改成 Card(onClick=…, modifier=…)
- 补 import kotlinx.serialization.encodeToString

环境固化(写进仓库,附理由注释,避免下次重踩):
- gradle.properties 加 kotlin.compiler.execution.strategy=in-process
  (受限环境里 Kotlin 编译守护进程连不回来)
- settings.gradle.kts 先走阿里云镜像再回落 google()/mavenCentral(),依赖下载从 5 分钟 2MB 变 90 秒 94MB
- 工程内 debug keystore(android/keystore/debug.keystore)+ app/build.gradle.kts 显式指向
  (受限用户对 ~/.android 无写权限,用系统默认 debug.keystore 会 AccessDenied)
- app/src/debug/AndroidManifest.xml 给 debug 变体放开明文 HTTP:
  真机连 http://192.168.8.x:8100 需要它,release 仍受 network_security_config 白名单约束
- android/README.md 换成实测过的构建/安装说明 + 坑表

结果:BUILD SUCCESSFUL,app-debug.apk 9.94 MB。
未验证:本机没有 system-image、也没连设备,所以只到"能编译、能装",没跑过界面。
This commit is contained in:
dadao
2026-10-28 15:35:47 +08:00
parent 0919c8ecd3
commit 4565fad255
11 changed files with 607 additions and 35 deletions
@@ -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)
+58 -16
View File
@@ -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
BUILD SUCCESSFUL in 38s
app/build/outputs/apk/debug/app-debug.apk 9.94 MB
```
3. 运行 `app` 配置。后端需先在本机 8100 端口起来。
> 仓库里**没有提交 `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)
│ └── TokenStore.kt 令牌本地存储(TODO:换 EncryptedSharedPreferences)
└── ui/
├── Theme.kt 与 Web 一致的墨青 + 陶土色板
├── LoginScreen.kt 手机号 + 验证码(开发环境后端回显验证码)
└── HomeScreen.kt 工作区列表 / 新建 / 生成邀请码
├── 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/<wsId>/<code>` 已在 Manifest 注册,但落地处理尚未实现。
- 邀请链接 `https://dadaotask.com/join/<wsId>/<code>` 已在 Manifest 注册,落地处理尚未实现。
- 未在本机跑过模拟器(无 system-image、无连接设备),端到端只验证到"能编译、能装"。
+17
View File
@@ -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
+14
View File
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
debug 变体专用清单。
开发时后端跑在局域网明文 HTTP 上(http://192.168.8.x:8100),系统默认禁止明文流量,
真机装上会直接连不上。这里只在 debug 放开明文,release 仍走 network_security_config 的白名单。
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<application
android:usesCleartextTraffic="true"
tools:replace="android:usesCleartextTraffic" />
</manifest>
@@ -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 })
}
}
}
}
@@ -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<Tag> = emptyList(),
val knowledge: KnowledgeInfo = KnowledgeInfo(),
val source: SourceRef? = null,
)
@Serializable
data class KnowledgePage(
val items: List<KnowledgeDoc> = 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<String, Any> = 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<String> = 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 <reified T> call(
path: String,
body: String? = null,
@@ -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<KnowledgeDoc>() }
var tab by remember { mutableStateOf(KNOWLEDGE_TABS.first()) }
var loading by remember { mutableStateOf(true) }
var error by remember { mutableStateOf<String?>(null) }
var open by remember { mutableStateOf<KnowledgeDoc?>(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("关闭") }
}
},
)
}
@@ -73,6 +73,7 @@ fun TaskListScreen(
workspace: Workspace,
onBack: () -> Unit,
onOpenTickets: () -> Unit = {},
onOpenKnowledge: () -> Unit = {},
) {
val items = remember { mutableStateListOf<Item>() }
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 ->
+5
View File
@@ -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
Binary file not shown.
+7
View File
@@ -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()
}