三件欠账一次清:
1. 图片直链签名化
- 新增 `backend/app/services/signing.py`:HMAC-SHA256,密钥由 `JWT_SECRET` 域分离派生
(`attachment-raw-v1`),载荷 `workspace_id:attachment_id:exp`,默认 30 分钟
- `/raw` 支持 `?exp=&sig=`,不需要登录态;签名里绑了工作区,跨租户链接直接拒
- 附件对象多一个 `raw_url`,前端每次渲染现签,正文里只存不带凭据的 `/raw`
- 保留旧 `?token=` 通道,给未升级的客户端过渡
2. 安卓富文本编辑器
- `ui/RichTextEditorScreen.kt`:Markdown 工具栏(粗体/斜体/标题/列表/引用/代码/链接/图片),
选区包裹与行首前缀都按光标位置来;贴图走附件接口,插入签名直链
- 预览用 Markwon,图片用 Coil;正文落在一张白纸卡片里,不再贴屏幕边
- 从任务详情与知识点都能进,保存按 `body_md` 回写(PATCH 带 version)
3. 表格
- 只读渲染走 markdown-it 的 GFM 表,`display:block + overflow-x:auto`:
宽表横向滚动、窄表铺满,都不撑破抽屉
验证:pytest 74 passed;richtext 15 / smoke 7 / tickets 8 / knowledge 7 全绿;
tsc -b 0 错;vite build 通过(主包 299.42 kB / gzip 74.96 kB);
安卓 assembleDebug 成功并在 API 35 模拟器走通编辑与预览。
仍欠:保存后回列表而非回详情抽屉、图片压缩与缩略图、HTML 片段按文本显示。
This commit is contained in:
@@ -19,12 +19,15 @@ import androidx.compose.ui.platform.LocalContext
|
||||
import com.dadao.task.data.Api
|
||||
import com.dadao.task.data.TokenStore
|
||||
import com.dadao.task.data.Workspace
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import com.dadao.task.ui.BoardScreen
|
||||
import com.dadao.task.ui.DadaoTheme
|
||||
import com.dadao.task.ui.EditRequest
|
||||
import com.dadao.task.ui.HomeScreen
|
||||
import com.dadao.task.ui.KnowledgeScreen
|
||||
import com.dadao.task.ui.LoginScreen
|
||||
import com.dadao.task.ui.MembersScreen
|
||||
import com.dadao.task.ui.RichTextEditorScreen
|
||||
import com.dadao.task.ui.TaskListScreen
|
||||
import com.dadao.task.ui.TicketsScreen
|
||||
import com.dadao.task.ui.WorkspaceScaffold
|
||||
@@ -68,6 +71,8 @@ private fun AppRoot(joinUri: String?) {
|
||||
var current by remember { mutableStateOf<Workspace?>(null) }
|
||||
var tab by remember { mutableStateOf(WorkspaceTab.Tasks) }
|
||||
var unread by remember { mutableStateOf(0) }
|
||||
// 打开富文本编辑器时非空;关掉就恢复原来的界面
|
||||
var editing by remember { mutableStateOf<EditRequest?>(null) }
|
||||
|
||||
// 有本地令牌就先静默校验一次:令牌过期/被吊销要退回登录页,而不是进了主页再崩。
|
||||
LaunchedEffect(screen) {
|
||||
@@ -122,18 +127,62 @@ private fun AppRoot(joinUri: String?) {
|
||||
if (ws == null) {
|
||||
screen = Screen.Home
|
||||
} else {
|
||||
WorkspaceScaffold(
|
||||
workspace = ws,
|
||||
tab = tab,
|
||||
unread = unread,
|
||||
onTab = { tab = it },
|
||||
onBackHome = { screen = Screen.Home },
|
||||
) {
|
||||
when (tab) {
|
||||
WorkspaceTab.Tasks -> TaskListScreen(api = api, workspace = ws)
|
||||
WorkspaceTab.Tickets -> TicketsScreen(api = api, workspace = ws)
|
||||
WorkspaceTab.Knowledge -> KnowledgeScreen(api = api, workspace = ws)
|
||||
WorkspaceTab.Members -> MembersScreen(api = api, workspace = ws)
|
||||
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 },
|
||||
)
|
||||
|
||||
WorkspaceTab.Tickets -> TicketsScreen(api = api, workspace = ws)
|
||||
WorkspaceTab.Knowledge -> KnowledgeScreen(
|
||||
api = api,
|
||||
workspace = ws,
|
||||
onEdit = { editing = it },
|
||||
)
|
||||
|
||||
WorkspaceTab.Members -> MembersScreen(api = api, workspace = ws)
|
||||
}
|
||||
}
|
||||
|
||||
val request = editing
|
||||
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,
|
||||
)
|
||||
} else {
|
||||
api.updateItem(
|
||||
workspaceId = ws.id,
|
||||
itemId = request.itemId,
|
||||
version = request.version,
|
||||
title = title,
|
||||
body = body,
|
||||
)
|
||||
}
|
||||
},
|
||||
onClose = { editing = null },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
@@ -136,8 +138,29 @@ data class ItemDetail(
|
||||
val tags: List<Tag> = emptyList(),
|
||||
val version: Int = 1,
|
||||
val activities: List<Activity> = emptyList(),
|
||||
val attachments: List<Attachment> = emptyList(),
|
||||
)
|
||||
|
||||
/**
|
||||
* 附件。[rawUrl] 是后端签发的短时直链(带 exp/sig),
|
||||
* 正文里的图片就用它,**不要**把长期凭据写进正文。
|
||||
*/
|
||||
@Serializable
|
||||
data class Attachment(
|
||||
val id: String,
|
||||
@SerialName("item_id") val itemId: String = "",
|
||||
val filename: String = "",
|
||||
@SerialName("content_type") val contentType: String = "application/octet-stream",
|
||||
val size: Long = 0,
|
||||
@SerialName("uploader_id") val uploaderId: String = "",
|
||||
@SerialName("created_at") val createdAt: String = "",
|
||||
@SerialName("raw_url") val rawUrl: String? = null,
|
||||
@SerialName("can_delete") val canDelete: Boolean = false,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AttachmentPage(val items: List<Attachment> = emptyList())
|
||||
|
||||
@Serializable
|
||||
data class Invitation(
|
||||
val code: String,
|
||||
@@ -223,6 +246,7 @@ data class KnowledgeDoc(
|
||||
val tags: List<Tag> = emptyList(),
|
||||
val knowledge: KnowledgeInfo = KnowledgeInfo(),
|
||||
val source: SourceRef? = null,
|
||||
val attachments: List<Attachment> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
@@ -287,6 +311,13 @@ private data class KnowledgeCreateRequest(
|
||||
@Serializable
|
||||
private data class KnowledgeActionRequest(val action: String)
|
||||
|
||||
@Serializable
|
||||
private data class ItemUpdateRequest(
|
||||
val version: Int,
|
||||
val title: String? = null,
|
||||
@SerialName("body_md") val bodyMd: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class KnowledgeUpdateRequest(
|
||||
val version: Int,
|
||||
@@ -451,6 +482,67 @@ class Api(private val tokens: TokenStore) {
|
||||
auth = true,
|
||||
)
|
||||
|
||||
// ---- 附件与富文本 ----
|
||||
|
||||
suspend fun attachments(workspaceId: String, itemId: String): List<Attachment> =
|
||||
call<AttachmentPage>(
|
||||
path = "/workspaces/$workspaceId/items/$itemId/attachments",
|
||||
auth = true,
|
||||
).items
|
||||
|
||||
/**
|
||||
* 上传附件。返回的 [Attachment.rawUrl] 是短时签名直链,
|
||||
* 直接写进 Markdown 的 `` 即可,不需要把登录令牌塞进正文。
|
||||
*/
|
||||
suspend fun uploadAttachment(
|
||||
workspaceId: String,
|
||||
itemId: String,
|
||||
filename: String,
|
||||
mimeType: String,
|
||||
bytes: ByteArray,
|
||||
): Attachment = withContext(Dispatchers.IO) {
|
||||
val form = MultipartBody.Builder()
|
||||
.setType(MultipartBody.FORM)
|
||||
.addFormDataPart(
|
||||
"file",
|
||||
filename,
|
||||
bytes.toRequestBody(mimeType.toMediaTypeOrNull()),
|
||||
)
|
||||
.build()
|
||||
|
||||
val builder = Request.Builder()
|
||||
.url("$base/workspaces/$workspaceId/items/$itemId/attachments")
|
||||
.post(form)
|
||||
.header("Accept", "application/json")
|
||||
tokens.accessToken?.let { builder.header("Authorization", "Bearer $it") }
|
||||
|
||||
try {
|
||||
http.newCall(builder.build()).execute().use { response ->
|
||||
val text = response.body?.string().orEmpty()
|
||||
val envelope = runCatching { json.decodeFromString<Envelope<Attachment>>(text) }
|
||||
.getOrElse { throw ApiException(1500, "上传失败(HTTP ${response.code})") }
|
||||
if (envelope.code != 0) throw ApiException(envelope.code, envelope.message, envelope.requestId)
|
||||
envelope.data ?: throw ApiException(1500, "上传响应缺少 data")
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
throw unreachable(e)
|
||||
}
|
||||
}
|
||||
|
||||
/** 保存正文/标题。PATCH 必须带 version,冲突由服务端返回 4009。 */
|
||||
suspend fun updateItem(
|
||||
workspaceId: String,
|
||||
itemId: String,
|
||||
version: Int,
|
||||
title: String? = null,
|
||||
body: String? = null,
|
||||
): ItemDetail = call(
|
||||
path = "/workspaces/$workspaceId/items/$itemId",
|
||||
body = json.encodeToString(ItemUpdateRequest(version, title, body)),
|
||||
auth = true,
|
||||
method = "PATCH",
|
||||
)
|
||||
|
||||
// ---- M2 工单 ----
|
||||
|
||||
/** role: assigned(派给我的)/ requested(我发起的)/ all。 */
|
||||
|
||||
@@ -55,7 +55,7 @@ private fun docTypeLabel(type: String): String = when (type) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun KnowledgeScreen(api: Api, workspace: Workspace) {
|
||||
fun KnowledgeScreen(api: Api, workspace: Workspace, onEdit: (EditRequest) -> Unit = {}) {
|
||||
val scope = rememberCoroutineScope()
|
||||
var tab by remember { mutableStateOf("draft") }
|
||||
var docs by remember { mutableStateOf<List<KnowledgeDoc>>(emptyList()) }
|
||||
@@ -168,6 +168,7 @@ fun KnowledgeScreen(api: Api, workspace: Workspace) {
|
||||
workspace = workspace,
|
||||
doc = target,
|
||||
onClose = { open = null },
|
||||
onEdit = onEdit,
|
||||
onChanged = {
|
||||
scope.launch { reload() }
|
||||
},
|
||||
@@ -249,6 +250,7 @@ private fun KnowledgeDetailDialog(
|
||||
onClose: () -> Unit,
|
||||
onChanged: () -> Unit,
|
||||
onFail: (Throwable) -> Unit,
|
||||
onEdit: (EditRequest) -> Unit,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
var action by remember { mutableStateOf(doc.status) }
|
||||
@@ -288,6 +290,21 @@ private fun KnowledgeDetailDialog(
|
||||
},
|
||||
confirmButton = {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
TextButton(
|
||||
onClick = {
|
||||
onEdit(
|
||||
EditRequest(
|
||||
itemId = doc.id,
|
||||
title = doc.title,
|
||||
body = doc.bodyMd,
|
||||
version = doc.version,
|
||||
titleEnabled = action == "draft",
|
||||
knowledge = true,
|
||||
),
|
||||
)
|
||||
onClose()
|
||||
},
|
||||
) { Text("编辑正文") }
|
||||
actions.forEach { (key, label) ->
|
||||
TextButton(onClick = {
|
||||
scope.launch {
|
||||
|
||||
@@ -0,0 +1,428 @@
|
||||
package com.dadao.task.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.provider.OpenableColumns
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
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.heightIn
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.TextRange
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import coil.compose.AsyncImage
|
||||
import com.dadao.task.data.Api
|
||||
import com.dadao.task.data.ApiException
|
||||
import com.dadao.task.data.Attachment
|
||||
import io.noties.markwon.Markwon
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/** 打开编辑器需要的最小上下文:改的是哪条、改前是什么。 */
|
||||
data class EditRequest(
|
||||
val itemId: String,
|
||||
val title: String,
|
||||
val body: String,
|
||||
val version: Int,
|
||||
val titleEnabled: Boolean = true,
|
||||
val knowledge: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
* 富文本编辑器(Markdown 存储)。
|
||||
*
|
||||
* 为什么存 Markdown 而不是 HTML:任务备注、工单说明、知识正文都要能被检索、
|
||||
* 被 LLM 读、被导出;HTML 里混着行内样式,清洗成本远高于收益。
|
||||
* 因此编辑区写 Markdown,预览用 Markwon 渲染,图片传附件后用签名直链插入。
|
||||
*/
|
||||
@Composable
|
||||
fun RichTextEditorScreen(
|
||||
api: Api,
|
||||
workspaceId: String,
|
||||
itemId: String,
|
||||
initialTitle: String,
|
||||
initialBody: String,
|
||||
/** 保存动作由调用方决定:事项走 PATCH /items,知识走 PATCH /knowledge。 */
|
||||
save: suspend (title: String?, body: String) -> Unit,
|
||||
titleEnabled: Boolean = true,
|
||||
onClose: (saved: Boolean) -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
var title by remember { mutableStateOf(initialTitle) }
|
||||
var field by remember { mutableStateOf(TextFieldValue(initialBody)) }
|
||||
var preview by remember { mutableStateOf(false) }
|
||||
var saving by remember { mutableStateOf(false) }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
var images by remember { mutableStateOf(emptyList<Attachment>()) }
|
||||
|
||||
// 传过的图片:进来先拉一份,预览区与「图片」栏都用它
|
||||
LaunchedEffect(itemId) {
|
||||
runCatching { api.attachments(workspaceId, itemId) }
|
||||
.onSuccess { images = it.filter { row -> row.contentType.startsWith("image/") } }
|
||||
}
|
||||
|
||||
val picker = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
|
||||
if (uri == null) return@rememberLauncherForActivityResult
|
||||
scope.launch {
|
||||
saving = true
|
||||
try {
|
||||
val bytes = withContext(Dispatchers.IO) {
|
||||
context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
|
||||
}
|
||||
if (bytes == null) {
|
||||
error = "读不到这张图片,换个来源试试"
|
||||
return@launch
|
||||
}
|
||||
val name = displayName(context, uri) ?: "image-${System.currentTimeMillis()}.png"
|
||||
val mime = context.contentResolver.getType(uri) ?: "image/png"
|
||||
val created = api.uploadAttachment(workspaceId, itemId, name, mime, bytes)
|
||||
images = images + created
|
||||
// 插入用签名直链:它会过期,所以库里存的是干净的相对路径
|
||||
field = insert(field, "})\n")
|
||||
} catch (e: ApiException) {
|
||||
error = e.message
|
||||
} catch (e: Exception) {
|
||||
error = "上传失败:${e.message ?: "未知错误"}"
|
||||
} finally {
|
||||
saving = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(Paper)
|
||||
.statusBarsPadding(),
|
||||
) {
|
||||
// 顶部:取消 / 状态 / 预览 / 保存
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
GhostButton("取消", onClick = { onClose(false) })
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
if (preview) "预览" else "编辑",
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = Ink,
|
||||
)
|
||||
Text(
|
||||
"${field.text.length} 字 · Markdown",
|
||||
fontSize = 11.sp,
|
||||
color = InkMuted,
|
||||
)
|
||||
}
|
||||
if (saving) {
|
||||
CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp, color = Primary)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
}
|
||||
PrimaryButton(if (preview) "继续写" else "预览", onClick = { preview = !preview })
|
||||
Spacer(Modifier.width(8.dp))
|
||||
PrimaryButton(
|
||||
"保存",
|
||||
enabled = !saving,
|
||||
onClick = {
|
||||
scope.launch {
|
||||
saving = true
|
||||
error = null
|
||||
try {
|
||||
save(if (titleEnabled) title else null, field.text)
|
||||
onClose(true)
|
||||
} catch (e: ApiException) {
|
||||
error = if (e.code == 4009) "刚被别人改过,请退出重进再改一次" else e.message
|
||||
} catch (e: Exception) {
|
||||
error = e.message ?: "保存失败"
|
||||
} finally {
|
||||
saving = false
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
HorizontalDivider(color = Line)
|
||||
|
||||
error?.let {
|
||||
Text(
|
||||
it,
|
||||
color = Rust,
|
||||
fontSize = 13.sp,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(RustSoft)
|
||||
.padding(horizontal = 14.dp, vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
|
||||
// 编辑区做成一张纸:标题、工具栏、正文在同一张纸上,边界清楚
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.clip(RoundedCornerShape(14.dp))
|
||||
.background(Color.White)
|
||||
.border(1.dp, Line, RoundedCornerShape(14.dp)),
|
||||
) {
|
||||
if (titleEnabled) {
|
||||
BasicTextField(
|
||||
value = title,
|
||||
onValueChange = { title = it },
|
||||
singleLine = true,
|
||||
textStyle = TextStyle(
|
||||
fontSize = 19.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Ink,
|
||||
),
|
||||
cursorBrush = SolidColor(Primary),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
decorationBox = { inner ->
|
||||
if (title.isBlank()) Text("标题", fontSize = 19.sp, color = InkMuted)
|
||||
inner()
|
||||
},
|
||||
)
|
||||
HorizontalDivider(color = Line)
|
||||
} else if (preview) {
|
||||
Text(
|
||||
title.ifBlank { initialTitle },
|
||||
fontSize = 19.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Ink,
|
||||
modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
)
|
||||
HorizontalDivider(color = Line)
|
||||
}
|
||||
|
||||
if (preview) {
|
||||
PreviewBody(field.text, images, Modifier.weight(1f))
|
||||
} else {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.horizontalScroll(rememberScrollState())
|
||||
.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
ToolButton("B", bold = true) { field = wrap(field, "**") }
|
||||
ToolButton("I", italic = true) { field = wrap(field, "*") }
|
||||
ToolButton("标题") { field = prefixLines(field, "## ") }
|
||||
ToolButton("列表") { field = prefixLines(field, "- ") }
|
||||
ToolButton("引用") { field = prefixLines(field, "> ") }
|
||||
ToolButton("代码") { field = wrap(field, "`") }
|
||||
ToolButton("链接") { field = insert(field, "[文字](https://)") }
|
||||
ToolButton("图片") { picker.launch("image/*") }
|
||||
}
|
||||
HorizontalDivider(color = Line)
|
||||
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
) {
|
||||
BasicTextField(
|
||||
value = field,
|
||||
onValueChange = { field = it },
|
||||
textStyle = TextStyle(fontSize = 15.sp, color = Ink, lineHeight = 24.sp),
|
||||
cursorBrush = SolidColor(Primary),
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.navigationBarsPadding(),
|
||||
decorationBox = { inner ->
|
||||
if (field.text.isBlank()) {
|
||||
Text(
|
||||
"写点什么。「B」「列表」在光标处插语法,图片直接选会自动上传。",
|
||||
fontSize = 15.sp,
|
||||
color = InkMuted,
|
||||
lineHeight = 24.sp,
|
||||
)
|
||||
}
|
||||
inner()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PreviewBody(
|
||||
source: String,
|
||||
images: List<Attachment>,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val markwon = remember { Markwon.create(context) }
|
||||
val text = remember(source) {
|
||||
// 预览里图片单独排一栏(Markwon 核心版不联网取图),正文里的图片语法换成占位说明
|
||||
source.replace(Regex("!\\[[^\\]]*\\]\\([^)]*\\)"), "〔图片〕")
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 14.dp, vertical = 12.dp)
|
||||
.navigationBarsPadding(),
|
||||
) {
|
||||
if (images.isNotEmpty()) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
images.forEach { image ->
|
||||
AsyncImage(
|
||||
model = image.rawUrl,
|
||||
contentDescription = image.filename,
|
||||
modifier = Modifier
|
||||
.size(96.dp)
|
||||
.clip(RoundedCornerShape(10.dp)),
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
}
|
||||
|
||||
if (text.isBlank()) {
|
||||
Text("还没有内容", fontSize = 14.sp, color = InkMuted)
|
||||
} else {
|
||||
AndroidView(
|
||||
factory = { ctx ->
|
||||
android.widget.TextView(ctx).apply {
|
||||
setTextIsSelectable(true)
|
||||
textSize = 15f
|
||||
setPadding(0, 0, 0, 0)
|
||||
setLineSpacing(0f, 1.5f)
|
||||
setTextColor(android.graphics.Color.parseColor("#4A5854"))
|
||||
markwon.setMarkdown(this, text)
|
||||
}
|
||||
},
|
||||
update = { view -> markwon.setMarkdown(view, text) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 120.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 工具条按钮:小、方、无多余留白,横滑容得下。 */
|
||||
@Composable
|
||||
private fun ToolButton(
|
||||
label: String,
|
||||
bold: Boolean = false,
|
||||
italic: Boolean = false,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(PaperSunken)
|
||||
.clickable { onClick() }
|
||||
.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
) {
|
||||
Text(
|
||||
label,
|
||||
fontSize = 13.sp,
|
||||
color = InkSoft,
|
||||
fontWeight = if (bold) FontWeight.Bold else FontWeight.Medium,
|
||||
fontStyle = if (italic) FontStyle.Italic else null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** 把选区包起来:没选就插一对并落光标在中间,选了就把选中的包进去。 */
|
||||
private fun wrap(field: TextFieldValue, token: String): TextFieldValue {
|
||||
val start = field.selection.start.coerceIn(0, field.text.length)
|
||||
val end = field.selection.end.coerceIn(start, field.text.length)
|
||||
val picked = field.text.substring(start, end)
|
||||
val next = field.text.substring(0, start) + token + picked + token + field.text.substring(end)
|
||||
val caret = if (picked.isEmpty()) start + token.length else start + picked.length + token.length * 2
|
||||
return field.copy(text = next, selection = TextRange(caret))
|
||||
}
|
||||
|
||||
/** 行首插前缀(标题/列表/引用),一次处理选区覆盖的每一行。 */
|
||||
private fun prefixLines(field: TextFieldValue, prefix: String): TextFieldValue {
|
||||
val start = field.selection.start.coerceIn(0, field.text.length)
|
||||
val end = field.selection.end.coerceIn(start, field.text.length)
|
||||
val lineStart = field.text.lastIndexOf('\n', (start - 1).coerceAtLeast(0)).let {
|
||||
if (it < 0 || start == 0) 0 else it + 1
|
||||
}
|
||||
val before = field.text.substring(0, lineStart)
|
||||
val block = field.text.substring(lineStart, end)
|
||||
val after = field.text.substring(end)
|
||||
val nextBlock = block.split('\n').joinToString("\n") { line ->
|
||||
if (line.startsWith(prefix)) line else prefix + line
|
||||
}
|
||||
val next = before + nextBlock + after
|
||||
return field.copy(text = next, selection = TextRange(lineStart + nextBlock.length))
|
||||
}
|
||||
|
||||
private fun insert(field: TextFieldValue, snippet: String): TextFieldValue {
|
||||
val start = field.selection.start.coerceIn(0, field.text.length)
|
||||
val end = field.selection.end.coerceIn(start, field.text.length)
|
||||
val next = field.text.substring(0, start) + snippet + field.text.substring(end)
|
||||
return field.copy(text = next, selection = TextRange(start + snippet.length))
|
||||
}
|
||||
|
||||
private fun displayName(context: Context, uri: Uri): String? = runCatching {
|
||||
context.contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)
|
||||
?.use { cursor -> if (cursor.moveToFirst()) cursor.getString(0) else null }
|
||||
}.getOrNull()
|
||||
@@ -66,6 +66,8 @@ fun TaskDetailSheet(
|
||||
itemId: String,
|
||||
onClose: () -> Unit,
|
||||
onChanged: () -> Unit,
|
||||
/** 进富文本编辑器改正文。标题与状态留在抽屉里,只有正文要更大地方。 */
|
||||
onEdit: (ItemDetail) -> Unit = {},
|
||||
) {
|
||||
val sheet = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
val scope = rememberCoroutineScope()
|
||||
@@ -125,6 +127,8 @@ fun TaskDetailSheet(
|
||||
)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
if (d.kind == "ticket") SoftPill("工单", Clay, ClaySoft)
|
||||
Spacer(Modifier.weight(1f))
|
||||
GhostButton("编辑正文", onClick = { onEdit(d) })
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
OutlinedTextField(
|
||||
|
||||
@@ -51,7 +51,7 @@ private val views = listOf(
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun TaskListScreen(api: Api, workspace: Workspace) {
|
||||
fun TaskListScreen(api: Api, workspace: Workspace, onEdit: (EditRequest) -> Unit = {}) {
|
||||
val scope = rememberCoroutineScope()
|
||||
var view by remember { mutableStateOf("active") }
|
||||
var mode by remember { mutableStateOf("list") }
|
||||
@@ -193,6 +193,10 @@ fun TaskListScreen(api: Api, workspace: Workspace) {
|
||||
itemId = id,
|
||||
onClose = { openId = null },
|
||||
onChanged = { scope.launch { reload() } },
|
||||
onEdit = {
|
||||
openId = null
|
||||
onEdit(EditRequest(it.id, it.title, it.bodyMd, it.version))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user