之前安卓代码一行都没编译过,这次从零把编译链搭起来,第一遍编译就炸了 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:
@@ -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
@@ -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、无连接设备),端到端只验证到"能编译、能装"。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ->
|
||||
|
||||
@@ -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.
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user