Compare commits
8
Commits
4e3f56af79
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
45d3222db6 | ||
|
|
4cb054d32f | ||
|
|
d58cf7922e | ||
|
|
e127d5bd40 | ||
|
|
f54df70811 | ||
|
|
4b7627d70e | ||
|
|
28eab4ae5f | ||
|
|
dcd77c6b35 |
@@ -27,8 +27,8 @@ android {
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
// 版本号是 OTA 的唯一判据:后端只比对 version_code,改这里才算"有新版本"
|
||||
versionCode = 3
|
||||
versionName = "0.3.0"
|
||||
versionCode = 6
|
||||
versionName = "0.5.1"
|
||||
buildConfigField("String", "API_BASE", "\"$apiBase\"")
|
||||
}
|
||||
|
||||
@@ -112,6 +112,10 @@ dependencies {
|
||||
// 表格与删除线:Web 端正文支持,安卓不能把 | 管道符原样吐给读者
|
||||
implementation("io.noties.markwon:ext-tables:4.6.2")
|
||||
implementation("io.noties.markwon:ext-strikethrough:4.6.2")
|
||||
// 正文图片:微信公众号转存后图片是正文里的 markdown 图,不能只当末尾附件
|
||||
implementation("io.noties.markwon:image:4.6.2")
|
||||
// 底部导航图标用官方 Material 图标集,不自己画五个 ImageVector
|
||||
implementation("androidx.compose.material:material-icons-core")
|
||||
implementation("io.coil-kt:coil-compose:2.7.0")
|
||||
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ import com.dadao.task.ui.TicketsScreen
|
||||
import com.dadao.task.ui.UpdateDialog
|
||||
import com.dadao.task.ui.WorkspaceScaffold
|
||||
import com.dadao.task.ui.WorkspaceTab
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
|
||||
@@ -114,10 +115,14 @@ private fun AppRoot(joinUri: String?) {
|
||||
.onFailure { pendingJoin = null }
|
||||
}
|
||||
|
||||
// 未读数:进工作区、切页面、或通知页标了已读(unreadRefresh)时刷新
|
||||
// 未读数:进工作区、切页面、或通知页标了已读(unreadRefresh)时刷新;
|
||||
// 停留不动时每 30 秒再拉一次,这样"任务被指派/快到期/已逾期"不用切页面也能看到角标。
|
||||
LaunchedEffect(current?.id, tab, unreadRefresh) {
|
||||
val ws = current ?: return@LaunchedEffect
|
||||
while (true) {
|
||||
unread = api.unreadCount(ws.id)
|
||||
delay(30_000)
|
||||
}
|
||||
}
|
||||
|
||||
// 版本检查:登录成功后查一次就够,查不到(网络、没发布过包)就当没有新版
|
||||
@@ -234,11 +239,15 @@ private fun AppRoot(joinUri: String?) {
|
||||
WorkspaceTab.Notifications -> NotificationsScreen(
|
||||
api = api,
|
||||
workspaceId = ws.id,
|
||||
onOpenItem = { itemId, isTicket ->
|
||||
// 通知点开后跳到对应列表并展开那一条;通知本身不带数据,页面自己回源
|
||||
onOpenItem = { itemId, itemKind ->
|
||||
// 通知点开后跳到对应页签并展开那一条;通知本身不带数据,页面自己回源
|
||||
reopenItem = itemId
|
||||
reopenKnowledge = false
|
||||
tab = if (isTicket) WorkspaceTab.Tickets else WorkspaceTab.Tasks
|
||||
reopenKnowledge = itemKind == "knowledge"
|
||||
tab = when (itemKind) {
|
||||
"knowledge" -> WorkspaceTab.Knowledge
|
||||
"ticket" -> WorkspaceTab.Tickets
|
||||
else -> WorkspaceTab.Tasks
|
||||
}
|
||||
},
|
||||
onUnreadChanged = { unreadRefresh += 1 },
|
||||
)
|
||||
|
||||
@@ -328,6 +328,8 @@ data class AppNotification(
|
||||
val id: String,
|
||||
val kind: String = "",
|
||||
@SerialName("item_id") val itemId: String? = null,
|
||||
/** task / ticket / knowledge:决定点开跳到哪个页签。 */
|
||||
@SerialName("item_kind") val itemKind: String? = null,
|
||||
@SerialName("item_title") val itemTitle: String? = null,
|
||||
val title: String = "",
|
||||
val body: String = "",
|
||||
@@ -488,6 +490,28 @@ class Api(private val tokens: TokenStore) {
|
||||
/** 当前生效的后端地址(不含 /api/v1),登录页展示与修改用。 */
|
||||
fun currentOrigin(): String = origin
|
||||
|
||||
/**
|
||||
* 把正文里的图片地址补成能直接加载的绝对地址。
|
||||
*
|
||||
* 后端存正文时把远程图抓下来,改写成 `/api/v1/.../attachments/{id}/raw?exp=..&sig=..`,
|
||||
* 签名 30 分钟就过期,所以加载时一律丢掉旧签名、换成当前 access token:
|
||||
* 阅读长文不该出现"刚打开时图还在,翻到后面全裂了"。
|
||||
*/
|
||||
fun mediaUrl(raw: String): String? {
|
||||
val src = raw.trim().takeIf { it.isNotBlank() } ?: return null
|
||||
val absolute = when {
|
||||
src.startsWith("http://", ignoreCase = true) ||
|
||||
src.startsWith("https://", ignoreCase = true) -> src
|
||||
src.startsWith("//") -> "https:$src"
|
||||
src.startsWith("/") -> origin + src
|
||||
else -> "$origin/$src"
|
||||
}
|
||||
if (!absolute.contains("/attachments/") || !absolute.contains("/raw")) return absolute
|
||||
val token = tokens.accessToken?.takeIf { it.isNotBlank() } ?: return absolute
|
||||
return absolute.substringBefore('?') + "?token=" +
|
||||
java.net.URLEncoder.encode(token, "UTF-8")
|
||||
}
|
||||
|
||||
/** 运行时切换后端地址;传 null 恢复编译期默认值。 */
|
||||
fun setOrigin(value: String?) {
|
||||
tokens.apiBase = value?.trim()?.trimEnd('/')?.takeIf { it.isNotBlank() }
|
||||
@@ -785,9 +809,20 @@ class Api(private val tokens: TokenStore) {
|
||||
|
||||
// ---- M2 工单 ----
|
||||
|
||||
/** role: assigned(派给我的)/ requested(我发起的)/ all。 */
|
||||
suspend fun tickets(workspaceId: String, role: String = "assigned"): TicketPage =
|
||||
call(path = "/workspaces/$workspaceId/tickets?role=$role", auth = true)
|
||||
/** role: assigned(派给我的)/ requested(我发起的)/ all;query 搜标题与正文。 */
|
||||
suspend fun tickets(
|
||||
workspaceId: String,
|
||||
role: String = "assigned",
|
||||
query: String? = null,
|
||||
): TicketPage {
|
||||
val params = buildString {
|
||||
append("?role=").append(role)
|
||||
if (!query.isNullOrBlank()) {
|
||||
append("&q=").append(java.net.URLEncoder.encode(query, "UTF-8"))
|
||||
}
|
||||
}
|
||||
return call(path = "/workspaces/$workspaceId/tickets$params", auth = true)
|
||||
}
|
||||
|
||||
/**
|
||||
* 工单动作。状态机在服务端,这里只发"做什么",不自己算目标状态——
|
||||
|
||||
@@ -12,30 +12,41 @@ import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Build
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.automirrored.filled.List
|
||||
import androidx.compose.material.icons.filled.Notifications
|
||||
import androidx.compose.material.icons.filled.Person
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
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.vector.ImageVector
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.dadao.task.data.Workspace
|
||||
|
||||
/** 手机端的去处。看板不是独立入口,而是任务页顶部的一个切换(屏幕太窄,塞不下更多 tab)。 */
|
||||
enum class WorkspaceTab(val label: String) {
|
||||
Tasks("任务"),
|
||||
Tickets("工单"),
|
||||
Knowledge("知识"),
|
||||
Notifications("通知"),
|
||||
Members("成员"),
|
||||
enum class WorkspaceTab(val label: String, val icon: ImageVector) {
|
||||
Tasks("任务", Icons.Filled.CheckCircle),
|
||||
Tickets("工单", Icons.Filled.Build),
|
||||
Knowledge("知识", Icons.AutoMirrored.Filled.List),
|
||||
Notifications("通知", Icons.Filled.Notifications),
|
||||
Members("成员", Icons.Filled.Person),
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -85,21 +96,40 @@ fun WorkspaceScaffold(
|
||||
modifier = Modifier.clickable { onBackHome() },
|
||||
)
|
||||
}
|
||||
// 未读入口:铃铛常驻,红点只表达"有事没看"。
|
||||
// 之前这里只有一个光秃秃的数字,既没有图标也点不动,等于把入口做成了装饰。
|
||||
Box(
|
||||
Modifier
|
||||
.size(40.dp)
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.clickable { onTab(WorkspaceTab.Notifications) },
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.Notifications,
|
||||
contentDescription = if (unread > 0) "通知,$unread 条未读" else "通知",
|
||||
tint = if (unread > 0) Ink else InkMuted,
|
||||
modifier = Modifier.size(23.dp),
|
||||
)
|
||||
if (unread > 0) {
|
||||
Box(
|
||||
Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.offset(x = 2.dp, y = 4.dp)
|
||||
.background(Clay, CircleShape)
|
||||
.padding(horizontal = 7.dp, vertical = 2.dp),
|
||||
.padding(horizontal = 5.dp, vertical = 1.dp),
|
||||
) {
|
||||
Text(
|
||||
if (unread > 99) "99+" else unread.toString(),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontSize = 10.sp,
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Box(Modifier.weight(1f).fillMaxWidth()) { content() }
|
||||
|
||||
@@ -117,15 +147,26 @@ fun WorkspaceScaffold(
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.clickable { if (!active) onTab(item) }
|
||||
.padding(vertical = 6.dp),
|
||||
.padding(vertical = 4.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
// 图标是主要识别入口:22dp 的色条在阳光下几乎看不见,文字又只有两个字
|
||||
Box(
|
||||
Modifier
|
||||
.size(width = 22.dp, height = 3.dp)
|
||||
.background(if (active) Primary else Color.Transparent, CircleShape),
|
||||
.background(
|
||||
if (active) PrimarySoft else Color.Transparent,
|
||||
RoundedCornerShape(10.dp),
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
.padding(horizontal = 12.dp, vertical = 3.dp),
|
||||
) {
|
||||
Icon(
|
||||
item.icon,
|
||||
contentDescription = item.label,
|
||||
tint = if (active) Primary else InkMuted,
|
||||
modifier = Modifier.size(21.dp),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(3.dp))
|
||||
Text(
|
||||
item.label,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
|
||||
@@ -13,6 +13,7 @@ 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.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
@@ -20,8 +21,10 @@ 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.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
@@ -29,8 +32,12 @@ import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
@@ -53,7 +60,6 @@ import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import com.dadao.task.data.Api
|
||||
@@ -62,11 +68,8 @@ import com.dadao.task.data.Attachment
|
||||
import com.dadao.task.data.KnowledgeCategory
|
||||
import com.dadao.task.data.KnowledgeDoc
|
||||
import com.dadao.task.data.Workspace
|
||||
import io.noties.markwon.Markwon
|
||||
import io.noties.markwon.ext.strikethrough.StrikethroughPlugin
|
||||
import io.noties.markwon.ext.tables.TablePlugin
|
||||
import io.noties.markwon.ext.tables.TableTheme
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@@ -189,6 +192,9 @@ fun KnowledgeScreen(
|
||||
// null = 全部;"none" = 未分类;其它是分类 id(服务端含子分类)
|
||||
var catFilter by remember { mutableStateOf<String?>(null) }
|
||||
var managing by remember { mutableStateOf(false) }
|
||||
var query by remember { mutableStateOf("") }
|
||||
var appliedQuery by remember { mutableStateOf("") }
|
||||
var refreshTick by remember { mutableStateOf(0) }
|
||||
|
||||
suspend fun loadCategories() {
|
||||
runCatching { api.knowledgeCategories(workspace.id) }
|
||||
@@ -210,6 +216,7 @@ fun KnowledgeScreen(
|
||||
statuses,
|
||||
categoryId = if (filter != null && filter != "none") filter else null,
|
||||
uncategorized = filter == "none",
|
||||
query = appliedQuery.ifBlank { null },
|
||||
)
|
||||
}
|
||||
.onSuccess {
|
||||
@@ -220,7 +227,14 @@ fun KnowledgeScreen(
|
||||
loading = false
|
||||
}
|
||||
|
||||
LaunchedEffect(tab, catFilter) { reload() }
|
||||
LaunchedEffect(tab, catFilter, appliedQuery, refreshTick) { reload() }
|
||||
// 搜索防抖:中文输入法上每敲一个拼音都会重组,350ms 内没有再动才真正发请求
|
||||
LaunchedEffect(query) {
|
||||
val target = query.trim()
|
||||
if (target == appliedQuery) return@LaunchedEffect
|
||||
delay(350)
|
||||
appliedQuery = target
|
||||
}
|
||||
LaunchedEffect(Unit) { loadCategories() }
|
||||
|
||||
// 从编辑器保存回来:本页是新建出来的,刷完列表再把那条详情重新打开。
|
||||
@@ -258,6 +272,14 @@ fun KnowledgeScreen(
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
SearchRefreshBar(
|
||||
value = query,
|
||||
onValue = { query = it },
|
||||
placeholder = "搜标题、正文或摘要…",
|
||||
refreshing = loading,
|
||||
onRefresh = { refreshTick += 1 },
|
||||
)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
knowledgeTabs.forEach { (key, label) ->
|
||||
SurfaceChip(text = label, selected = tab == key, onClick = { tab = key })
|
||||
@@ -294,14 +316,13 @@ fun KnowledgeScreen(
|
||||
if (!loading && docs.isEmpty()) {
|
||||
item {
|
||||
EmptyState(
|
||||
if (catFilter != null) {
|
||||
"这个分类下还没有知识"
|
||||
} else if (tab == "all") {
|
||||
"还没有知识"
|
||||
} else {
|
||||
"这一类里还没有知识"
|
||||
when {
|
||||
appliedQuery.isNotBlank() -> "没有搜到「$appliedQuery」"
|
||||
catFilter != null -> "这个分类下还没有知识"
|
||||
tab == "all" -> "还没有知识"
|
||||
else -> "这一类里还没有知识"
|
||||
},
|
||||
"把工单验收通过,或在任务详情里点「沉淀成知识」",
|
||||
"换个词试试,或把工单验收通过、在任务详情里点「沉淀成知识」",
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -365,7 +386,7 @@ fun KnowledgeScreen(
|
||||
|
||||
val target = open
|
||||
if (target != null) {
|
||||
KnowledgeDetailDialog(
|
||||
KnowledgeReader(
|
||||
api = api,
|
||||
workspace = workspace,
|
||||
doc = target,
|
||||
@@ -778,56 +799,6 @@ private fun CategoryEditorCard(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 正文的「看」形态:Markwon 把 Markdown 排成排版好的正文。
|
||||
*
|
||||
* 编辑走 RichTextEditorScreen,所以这里永远是只读——点开一条知识先看到内容本身,
|
||||
* 而不是一堆 `##`、`-` 原样摆着。
|
||||
*/
|
||||
@Composable
|
||||
private fun KnowledgeBody(text: String) {
|
||||
if (text.isBlank()) {
|
||||
Text("(还没有正文,点「编辑正文」补齐)", fontSize = 13.sp, color = InkMuted)
|
||||
return
|
||||
}
|
||||
val context = LocalContext.current
|
||||
// 表格/删除线插件:Web 正文支持表格,安卓照抄同一份 markdown 时要能画出来,
|
||||
// 否则 | 管道符会原样铺在正文里,像没解析。
|
||||
val markwon = remember {
|
||||
// 表格主题按 Web 的纸面来:1dp 浅线、表头浅底、偶数行淡一档。
|
||||
// 用默认主题的话边框是 1px 纯黑,一格一格的,像 Excel 截图。
|
||||
val density = context.resources.displayMetrics.density
|
||||
val tableTheme = TableTheme.Builder()
|
||||
.tableBorderWidth((1 * density).toInt())
|
||||
.tableBorderColor(android.graphics.Color.parseColor("#E3E7EF"))
|
||||
.tableCellPadding((8 * density).toInt())
|
||||
.tableHeaderRowBackgroundColor(android.graphics.Color.parseColor("#F4F5F9"))
|
||||
.tableOddRowBackgroundColor(android.graphics.Color.WHITE)
|
||||
.tableEvenRowBackgroundColor(android.graphics.Color.parseColor("#FAFAFC"))
|
||||
.build()
|
||||
Markwon.builder(context)
|
||||
.usePlugin(TablePlugin.create(tableTheme))
|
||||
.usePlugin(StrikethroughPlugin.create())
|
||||
.build()
|
||||
}
|
||||
AndroidView(
|
||||
factory = { ctx ->
|
||||
android.widget.TextView(ctx).apply {
|
||||
setTextIsSelectable(true)
|
||||
// 与 Web 阅读页对齐:正文 16sp、行高 1.7 —— 手机上读长文,14sp 太挤
|
||||
textSize = 16f
|
||||
setLineSpacing(0f, 1.7f)
|
||||
setTextColor(android.graphics.Color.parseColor("#1E293B"))
|
||||
markwon.setMarkdown(this, text)
|
||||
}
|
||||
},
|
||||
update = { view -> markwon.setMarkdown(view, text) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 80.dp),
|
||||
)
|
||||
}
|
||||
|
||||
private fun sourceLine(doc: KnowledgeDoc): String {
|
||||
val src = doc.source
|
||||
return if (src != null && src.title.isNotBlank()) {
|
||||
@@ -985,8 +956,15 @@ private fun KnowledgeComposerDialog(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 知识阅读页:整屏,而不是弹窗。
|
||||
*
|
||||
* 手机上弹窗上下各留一条黑边,正文被压进 560dp 高的小窗里翻几十屏。这里铺满屏幕:
|
||||
* 顶部只留返回、标题和编辑,底部是分类/标签与状态动作,正文独占剩下的全部高度。
|
||||
* 附件上传、分类标签、状态流转都保留在这一页——读和改不该是两套界面。
|
||||
*/
|
||||
@Composable
|
||||
private fun KnowledgeDetailDialog(
|
||||
private fun KnowledgeReader(
|
||||
api: Api,
|
||||
workspace: Workspace,
|
||||
doc: KnowledgeDoc,
|
||||
@@ -999,10 +977,10 @@ private fun KnowledgeDetailDialog(
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val context = LocalContext.current
|
||||
var action by remember { mutableStateOf(doc.status) }
|
||||
var version by remember { mutableStateOf(doc.version) }
|
||||
var categoryId by remember { mutableStateOf(doc.category?.id) }
|
||||
var tags by remember { mutableStateOf(doc.tags.joinToString(" ") { it.name }) }
|
||||
var action by remember(doc.id) { mutableStateOf(doc.status) }
|
||||
var version by remember(doc.id) { mutableStateOf(doc.version) }
|
||||
var categoryId by remember(doc.id) { mutableStateOf(doc.category?.id) }
|
||||
var tags by remember(doc.id) { mutableStateOf(doc.tags.joinToString(" ") { it.name }) }
|
||||
var savingMeta by remember { mutableStateOf(false) }
|
||||
var metaNotice by remember { mutableStateOf<String?>(null) }
|
||||
/**
|
||||
@@ -1047,35 +1025,88 @@ private fun KnowledgeDetailDialog(
|
||||
else -> emptyList()
|
||||
}
|
||||
|
||||
AlertDialog(
|
||||
// 正文已经内嵌的图片不再在末尾附件里重复一遍:转存公众号文章时,
|
||||
// 一篇十张图的文章会先读完十张图,再看到十个意义不明的「xxxx.jpg 128 KB」。
|
||||
val inlineIds = remember(doc.bodyMd) { inlineAttachmentIds(doc.bodyMd) }
|
||||
val fileAttachments = attachments.filterNot { it.id in inlineIds }
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = onClose,
|
||||
title = {
|
||||
Column {
|
||||
// 关键:默认 Dialog 宽度被限制在屏幕的 90% 左右,高度也会包住内容。
|
||||
// 这两项一起才真正拿到整块屏幕。
|
||||
properties = DialogProperties(
|
||||
usePlatformDefaultWidth = false,
|
||||
decorFitsSystemWindows = false,
|
||||
),
|
||||
) {
|
||||
Surface(Modifier.fillMaxSize(), color = Paper) {
|
||||
Column(Modifier.fillMaxSize().statusBarsPadding()) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 2.dp, end = 10.dp, top = 2.dp, bottom = 2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
IconButton(onClick = onClose) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "返回", tint = Ink)
|
||||
}
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
doc.title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 2,
|
||||
)
|
||||
Spacer(Modifier.height(3.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
SoftPill(docTypeLabel(doc.knowledge.docType))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
StatusPill(action)
|
||||
if (doc.knowledge.generatedBy == "rule") {
|
||||
Spacer(Modifier.width(6.dp))
|
||||
SoftPill("自动整理", Clay, ClaySoft)
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(doc.title, style = MaterialTheme.typography.titleMedium)
|
||||
}
|
||||
},
|
||||
text = {
|
||||
Column(
|
||||
// 阅读区给足高度:手机上一屏能看二十来行,不用一直往里滚
|
||||
Modifier.heightIn(min = 320.dp, max = 560.dp).verticalScroll(rememberScrollState()),
|
||||
}
|
||||
TextButton(onClick = {
|
||||
onEdit(
|
||||
EditRequest(
|
||||
itemId = doc.id,
|
||||
title = doc.title,
|
||||
body = doc.bodyMd,
|
||||
version = version,
|
||||
titleEnabled = action != "archived",
|
||||
knowledge = true,
|
||||
),
|
||||
)
|
||||
onClose()
|
||||
}) { Text("编辑") }
|
||||
}
|
||||
DividerLine()
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.weight(1f).fillMaxWidth(),
|
||||
contentPadding = PaddingValues(start = 20.dp, end = 20.dp, top = 16.dp, bottom = 24.dp),
|
||||
) {
|
||||
item {
|
||||
Text(sourceLine(doc), style = MaterialTheme.typography.labelSmall, color = InkMuted)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
// 点开就是「看」:正文用 Markwon 排好版再显示,要看原文/改内容点「编辑正文」
|
||||
KnowledgeBody(doc.bodyMd)
|
||||
}
|
||||
item {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
MarkdownBody(doc.bodyMd, api)
|
||||
}
|
||||
|
||||
// 附件:手册、日志、导出表格都能挂在这条知识下,点一下用浏览器打开
|
||||
Spacer(Modifier.height(14.dp))
|
||||
item {
|
||||
Spacer(Modifier.height(20.dp))
|
||||
SectionLabel("附件")
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
if (attachments.isEmpty()) "还没有附件" else "${attachments.size} 个",
|
||||
when {
|
||||
fileAttachments.isNotEmpty() -> "${fileAttachments.size} 个"
|
||||
inlineIds.isNotEmpty() -> "图片已内嵌在正文中"
|
||||
else -> "还没有附件"
|
||||
},
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = InkMuted,
|
||||
)
|
||||
@@ -1085,7 +1116,7 @@ private fun KnowledgeDetailDialog(
|
||||
onClick = { picker.launch(arrayOf("*/*")) },
|
||||
) { Text(if (uploading) "上传中…" else "上传附件") }
|
||||
}
|
||||
attachments.forEach { row ->
|
||||
fileAttachments.forEach { row ->
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(vertical = 2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
@@ -1105,7 +1136,8 @@ private fun KnowledgeDetailDialog(
|
||||
TextButton(
|
||||
onClick = {
|
||||
// raw_url 是带短时签名的相对路径,拼上当前后端地址才能在浏览器里打开
|
||||
val url = row.rawUrl?.let { api.currentOrigin() + it } ?: return@TextButton
|
||||
val url = row.rawUrl?.let { api.currentOrigin() + it }
|
||||
?: return@TextButton
|
||||
runCatching {
|
||||
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
|
||||
}.onFailure { onFail(it) }
|
||||
@@ -1113,10 +1145,12 @@ private fun KnowledgeDetailDialog(
|
||||
) { Text("打开") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 已发布的也能改(后端改完会重建检索切片);归档的先恢复成草稿再动,免得"收起来的"被悄悄改写
|
||||
if (action != "archived") {
|
||||
Spacer(Modifier.height(14.dp))
|
||||
item {
|
||||
Spacer(Modifier.height(20.dp))
|
||||
SectionLabel("分类")
|
||||
Row(
|
||||
modifier = Modifier.horizontalScroll(rememberScrollState()),
|
||||
@@ -1160,24 +1194,18 @@ private fun KnowledgeDetailDialog(
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
TextButton(
|
||||
onClick = {
|
||||
onEdit(
|
||||
EditRequest(
|
||||
itemId = doc.id,
|
||||
title = doc.title,
|
||||
body = doc.bodyMd,
|
||||
version = version,
|
||||
titleEnabled = action != "archived",
|
||||
knowledge = true,
|
||||
),
|
||||
)
|
||||
onClose()
|
||||
},
|
||||
) { Text("编辑正文") }
|
||||
}
|
||||
|
||||
DividerLine()
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.navigationBarsPadding()
|
||||
.horizontalScroll(rememberScrollState())
|
||||
.padding(horizontal = 8.dp, vertical = 2.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (action != "archived") {
|
||||
TextButton(
|
||||
enabled = !savingMeta,
|
||||
@@ -1224,10 +1252,9 @@ private fun KnowledgeDetailDialog(
|
||||
}) { Text(label) }
|
||||
}
|
||||
}
|
||||
},
|
||||
dismissButton = { TextButton(onClick = onClose) { Text("关闭") } },
|
||||
containerColor = Paper,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 附件列表里显示的大小:人与手机都不需要精确到字节。 */
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
package com.dadao.task.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.drawable.BitmapDrawable
|
||||
import android.net.Uri
|
||||
import android.util.LruCache
|
||||
import android.widget.TextView
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import com.dadao.task.data.Api
|
||||
import io.noties.markwon.Markwon
|
||||
import io.noties.markwon.ext.strikethrough.StrikethroughPlugin
|
||||
import io.noties.markwon.ext.tables.TablePlugin
|
||||
import io.noties.markwon.ext.tables.TableTheme
|
||||
import io.noties.markwon.image.ImageItem
|
||||
import io.noties.markwon.image.ImagesPlugin
|
||||
import io.noties.markwon.image.SchemeHandler
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/** 正文里引用过的附件 id:这些图已经内嵌在文中,不该再在末尾附件列表里重复一遍。 */
|
||||
private val attachmentRefRe =
|
||||
Regex("""/attachments/([0-9a-fA-F-]{36})/raw""")
|
||||
|
||||
fun inlineAttachmentIds(markdown: String): Set<String> =
|
||||
attachmentRefRe.findAll(markdown).map { it.groupValues[1] }.toSet()
|
||||
|
||||
/** 正文图片解码后的缓存:同一条知识反复进出阅读页时不必重新下载同一个文件。 */
|
||||
private val imageCache = object : LruCache<String, Bitmap>(32 * 1024) {
|
||||
override fun sizeOf(key: String, value: Bitmap): Int = value.byteCount / 1024
|
||||
}
|
||||
|
||||
private const val MAX_IMAGE_BYTES = 12L * 1024 * 1024
|
||||
|
||||
/**
|
||||
* 正文图片地址全是相对路径(`/api/v1/.../attachments/../raw?...`),
|
||||
* 而 Markwon 只把带 http/https scheme 的地址交给自定义 handler,相对路径会被
|
||||
* 默认 handler 当成未知协议直接丢掉。所以渲染前先把地址补成绝对地址。
|
||||
*/
|
||||
private val markdownImageRe = Regex("""(!\[[^\]]*\]\()([^)\s]+)([^)]*\))""")
|
||||
private val htmlImageRe = Regex("""(<img[^>]*\bsrc=["'])([^"']+)(["'])""", RegexOption.IGNORE_CASE)
|
||||
|
||||
private fun rewriteImageUrls(markdown: String, api: Api): String {
|
||||
val md = markdownImageRe.replace(markdown) { m ->
|
||||
val absolute = api.mediaUrl(m.groupValues[2]) ?: m.groupValues[2]
|
||||
m.groupValues[1] + absolute + m.groupValues[3]
|
||||
}
|
||||
return htmlImageRe.replace(md) { m ->
|
||||
val absolute = api.mediaUrl(m.groupValues[2]) ?: m.groupValues[2]
|
||||
m.groupValues[1] + absolute + m.groupValues[3]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 正文图片加载:后端把文章里的远程图抓成了附件,正文只留相对地址。
|
||||
*
|
||||
* 两条关键处理:
|
||||
* 1. 相对地址补上当前后端域名,附件链接再换成 `?token=`——签名 30 分钟过期,
|
||||
* 长文读到一半图全裂开比"没图"更难受。
|
||||
* 2. 按屏幕宽度重新采样,微信公众号的 677px 原图在手机上刚好占满一屏,
|
||||
* 超大截图也不会把正文顶成一条缝。
|
||||
*
|
||||
* 走 SchemeHandler 而不是自定义 AsyncDrawableLoader:Markwon 的图片管线会在
|
||||
* 后台线程调这里,我们一次性把「下载 + 解码 + 缩放」做完,直接交回 Drawable。
|
||||
*/
|
||||
private class TokenSchemeHandler(
|
||||
private val api: Api,
|
||||
private val context: Context,
|
||||
) : SchemeHandler() {
|
||||
|
||||
private val client = OkHttpClient.Builder()
|
||||
.connectTimeout(10, TimeUnit.SECONDS)
|
||||
.readTimeout(20, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
override fun supportedSchemes(): Collection<String> = listOf("http", "https")
|
||||
|
||||
override fun handle(destination: String, uri: Uri): ImageItem {
|
||||
val url = api.mediaUrl(destination)
|
||||
?: throw IOException("图片地址为空")
|
||||
imageCache.get(url)?.let {
|
||||
return ImageItem.withResult(BitmapDrawable(context.resources, it))
|
||||
}
|
||||
client.newCall(Request.Builder().url(url).build()).execute().use { response ->
|
||||
if (!response.isSuccessful) throw IOException("图片加载失败 HTTP ${response.code}")
|
||||
val body = response.body ?: throw IOException("图片响应为空")
|
||||
if (body.contentLength() > MAX_IMAGE_BYTES) throw IOException("图片超过 12MB")
|
||||
val bytes = body.bytes()
|
||||
if (bytes.size > MAX_IMAGE_BYTES) throw IOException("图片超过 12MB")
|
||||
|
||||
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds)
|
||||
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) throw IOException("图片解码失败")
|
||||
|
||||
val target = targetWidthPx(context)
|
||||
var sample = 1
|
||||
while (bounds.outWidth / (sample * 2) >= target) sample *= 2
|
||||
val decoded = BitmapFactory.decodeByteArray(
|
||||
bytes,
|
||||
0,
|
||||
bytes.size,
|
||||
BitmapFactory.Options().apply { inSampleSize = sample },
|
||||
) ?: throw IOException("图片解码失败")
|
||||
|
||||
val bitmap = if (decoded.width > target) {
|
||||
Bitmap.createScaledBitmap(
|
||||
decoded,
|
||||
target,
|
||||
(decoded.height * target / decoded.width).coerceAtLeast(1),
|
||||
true,
|
||||
)
|
||||
} else {
|
||||
decoded
|
||||
}
|
||||
// 解码出来的图没有密度信息,BitmapDrawable 会按 160dpi 基准再放大一遍;
|
||||
// 标成屏幕密度,drawable 的宽度才等于我们算好的目标像素
|
||||
bitmap.density = context.resources.displayMetrics.densityDpi
|
||||
imageCache.put(url, bitmap)
|
||||
return ImageItem.withResult(BitmapDrawable(context.resources, bitmap))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun targetWidthPx(context: Context): Int {
|
||||
val metrics = context.resources.displayMetrics
|
||||
return (metrics.widthPixels - 40 * metrics.density).toInt().coerceIn(320, 1600)
|
||||
}
|
||||
|
||||
/**
|
||||
* 正文的「看」形态:Markwon 把 Markdown 排成排版好的正文。
|
||||
*
|
||||
* 编辑走 RichTextEditorScreen,所以这里永远是只读——点开一条知识先看到内容本身,
|
||||
* 而不是一堆 `##`、`-` 原样摆着。图片、表格、链接都在这层解析。
|
||||
*/
|
||||
@Composable
|
||||
fun MarkdownBody(text: String, api: Api) {
|
||||
if (text.isBlank()) {
|
||||
androidx.compose.material3.Text(
|
||||
"(还没有正文,点「编辑」补齐)",
|
||||
fontSize = 13.sp,
|
||||
color = InkMuted,
|
||||
)
|
||||
return
|
||||
}
|
||||
val context = LocalContext.current
|
||||
// 图片地址先补成绝对地址:Markwon 的相对路径不会进自定义 scheme handler
|
||||
val body = remember(text, api) { rewriteImageUrls(text, api) }
|
||||
val markwon = remember(api) {
|
||||
// 表格主题按 Web 的纸面来:1dp 浅线、表头浅底、偶数行淡一档。
|
||||
// 用默认主题的话边框是 1px 纯黑,一格一格的,像 Excel 截图。
|
||||
val density = context.resources.displayMetrics.density
|
||||
val tableTheme = TableTheme.Builder()
|
||||
.tableBorderWidth((1 * density).toInt())
|
||||
.tableBorderColor(android.graphics.Color.parseColor("#E3E7EF"))
|
||||
.tableCellPadding((8 * density).toInt())
|
||||
.tableHeaderRowBackgroundColor(android.graphics.Color.parseColor("#F4F5F9"))
|
||||
.tableOddRowBackgroundColor(android.graphics.Color.WHITE)
|
||||
.tableEvenRowBackgroundColor(android.graphics.Color.parseColor("#FAFAFC"))
|
||||
.build()
|
||||
Markwon.builder(context)
|
||||
.usePlugin(TablePlugin.create(tableTheme))
|
||||
.usePlugin(StrikethroughPlugin.create())
|
||||
.usePlugin(
|
||||
ImagesPlugin.create { plugin ->
|
||||
// http/https 交给自己的 handler:拼域名、换 token、限宽
|
||||
plugin.addSchemeHandler(TokenSchemeHandler(api, context))
|
||||
},
|
||||
)
|
||||
.build()
|
||||
}
|
||||
AndroidView(
|
||||
factory = { ctx ->
|
||||
TextView(ctx).apply {
|
||||
// 阅读页正文 16sp、行高 1.7 —— 手机上读长文,14sp 太挤
|
||||
textSize = 16f
|
||||
setLineSpacing(0f, 1.7f)
|
||||
setTextColor(android.graphics.Color.parseColor("#1E293B"))
|
||||
// 不 setTextIsSelectable:可选中会吃掉链接点击,文章里的原文链接就点不动了
|
||||
linksClickable = true
|
||||
movementMethod = android.text.method.LinkMovementMethod.getInstance()
|
||||
markwon.setMarkdown(this, body)
|
||||
}
|
||||
},
|
||||
update = { view -> markwon.setMarkdown(view, body) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 80.dp),
|
||||
)
|
||||
}
|
||||
@@ -32,6 +32,7 @@ import androidx.compose.ui.unit.sp
|
||||
import com.dadao.task.data.Api
|
||||
import com.dadao.task.data.ApiException
|
||||
import com.dadao.task.data.AppNotification
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
@@ -44,7 +45,7 @@ import kotlinx.coroutines.launch
|
||||
fun NotificationsScreen(
|
||||
api: Api,
|
||||
workspaceId: String,
|
||||
onOpenItem: (itemId: String, isTicket: Boolean) -> Unit,
|
||||
onOpenItem: (itemId: String, itemKind: String) -> Unit,
|
||||
onUnreadChanged: () -> Unit,
|
||||
refreshKey: Int = 0,
|
||||
) {
|
||||
@@ -54,17 +55,25 @@ fun NotificationsScreen(
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
suspend fun reload() {
|
||||
loading = true
|
||||
suspend fun reload(silent: Boolean = false) {
|
||||
if (!silent) loading = true
|
||||
error = null
|
||||
runCatching { api.notifications(workspaceId, unreadOnly) }
|
||||
.onSuccess { rows = it.notifications }
|
||||
.onFailure { error = verifyStatusCode((it as? ApiException)?.code ?: 0) }
|
||||
loading = false
|
||||
if (!silent) loading = false
|
||||
}
|
||||
|
||||
LaunchedEffect(workspaceId, unreadOnly, refreshKey) { reload() }
|
||||
|
||||
// 停在通知页时也要能看到新动静:每 30 秒静默拉一次,不闪加载态。
|
||||
LaunchedEffect(workspaceId, unreadOnly) {
|
||||
while (true) {
|
||||
delay(30_000)
|
||||
reload(silent = true)
|
||||
}
|
||||
}
|
||||
|
||||
Column(Modifier.fillMaxSize().padding(horizontal = 16.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@@ -85,6 +94,26 @@ fun NotificationsScreen(
|
||||
bg = if (unreadOnly) PaperSunken else PrimarySoft,
|
||||
modifier = Modifier.clickable { unreadOnly = false },
|
||||
)
|
||||
Box(Modifier.weight(1f))
|
||||
SoftPill(
|
||||
text = "刷新",
|
||||
fg = InkMuted,
|
||||
bg = PaperSunken,
|
||||
modifier = Modifier.clickable { scope.launch { reload() } },
|
||||
)
|
||||
SoftPill(
|
||||
text = "全部已读",
|
||||
fg = InkMuted,
|
||||
bg = PaperSunken,
|
||||
modifier = Modifier.clickable {
|
||||
scope.launch {
|
||||
runCatching { api.markAllNotificationsRead(workspaceId) }
|
||||
rows = rows.map { if (it.readAt == null) it.copy(readAt = "read") else it }
|
||||
onUnreadChanged()
|
||||
reload(silent = true)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
@@ -121,7 +150,14 @@ fun NotificationsScreen(
|
||||
contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = 20.dp),
|
||||
) {
|
||||
items(rows, key = { it.id }) { n ->
|
||||
val isTicket = n.kind.startsWith("ticket_")
|
||||
// 知识/工单/任务三种落点:优先看后端给的 item_kind,老数据再退回 kind 前缀
|
||||
val isKnowledge = n.kind.startsWith("knowledge_") || n.itemKind == "knowledge"
|
||||
val isTicket = !isKnowledge && (n.kind.startsWith("ticket_") || n.itemKind == "ticket")
|
||||
val targetKind = when {
|
||||
isKnowledge -> "knowledge"
|
||||
isTicket -> "ticket"
|
||||
else -> "task"
|
||||
}
|
||||
SoftCard(
|
||||
Modifier.fillMaxWidth(),
|
||||
onClick = {
|
||||
@@ -131,7 +167,7 @@ fun NotificationsScreen(
|
||||
rows = rows.map { if (it.id == n.id) it.copy(readAt = "read") else it }
|
||||
onUnreadChanged()
|
||||
}
|
||||
n.itemId?.let { onOpenItem(it, isTicket) }
|
||||
n.itemId?.let { onOpenItem(it, targetKind) }
|
||||
}
|
||||
},
|
||||
) {
|
||||
@@ -143,7 +179,11 @@ fun NotificationsScreen(
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = if (isTicket) "工" else "记",
|
||||
text = when {
|
||||
isKnowledge -> "知"
|
||||
isTicket -> "工"
|
||||
else -> "记"
|
||||
},
|
||||
fontSize = 11.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = if (n.readAt == null) Primary else InkMuted,
|
||||
|
||||
@@ -40,6 +40,7 @@ import com.dadao.task.data.Api
|
||||
import com.dadao.task.data.ApiException
|
||||
import com.dadao.task.data.Item
|
||||
import com.dadao.task.data.Workspace
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
// 「全部」放第一个,与 Web 任务页保持一致;默认选中的视图在下面单独定,不靠顺序。
|
||||
@@ -67,13 +68,16 @@ fun TaskListScreen(
|
||||
var loading by remember { mutableStateOf(true) }
|
||||
var draft by remember { mutableStateOf("") }
|
||||
var query by remember { mutableStateOf("") }
|
||||
// 真正发给服务端的搜索词:等用户停手 350ms 再发,否则每敲一个字打一次接口
|
||||
var appliedQuery by remember { mutableStateOf("") }
|
||||
var refreshTick by remember { mutableStateOf(0) }
|
||||
var openId by remember { mutableStateOf<String?>(null) }
|
||||
var detailRefresh by remember { mutableStateOf(0) }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
suspend fun reload() {
|
||||
loading = true
|
||||
runCatching { api.items(workspace.id, view = view, query = query.ifBlank { null }) }
|
||||
runCatching { api.items(workspace.id, view = view, query = appliedQuery.ifBlank { null }) }
|
||||
.onSuccess {
|
||||
items = it.items
|
||||
error = null
|
||||
@@ -82,7 +86,14 @@ fun TaskListScreen(
|
||||
loading = false
|
||||
}
|
||||
|
||||
LaunchedEffect(view, query) { reload() }
|
||||
LaunchedEffect(view, appliedQuery, refreshTick) { reload() }
|
||||
|
||||
LaunchedEffect(query) {
|
||||
val target = query.trim()
|
||||
if (target == appliedQuery) return@LaunchedEffect
|
||||
delay(350)
|
||||
appliedQuery = target
|
||||
}
|
||||
|
||||
// 从编辑器保存回来:本页是新建出来的,先刷数据再把那一条详情重新打开。
|
||||
LaunchedEffect(reopenItem) {
|
||||
@@ -136,25 +147,12 @@ fun TaskListScreen(
|
||||
}
|
||||
|
||||
item {
|
||||
OutlinedTextField(
|
||||
SearchRefreshBar(
|
||||
value = query,
|
||||
onValueChange = { query = it },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
placeholder = {
|
||||
Text(
|
||||
"搜标题或正文…",
|
||||
color = InkMuted,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
},
|
||||
singleLine = true,
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
colors = TextFieldDefaults.colors(
|
||||
focusedContainerColor = Color.White,
|
||||
unfocusedContainerColor = Color.White,
|
||||
focusedIndicatorColor = Primary,
|
||||
unfocusedIndicatorColor = Line,
|
||||
),
|
||||
onValue = { query = it },
|
||||
placeholder = "搜标题或正文…",
|
||||
refreshing = loading,
|
||||
onRefresh = { refreshTick += 1 },
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ import com.dadao.task.data.ApiException
|
||||
import com.dadao.task.data.Member
|
||||
import com.dadao.task.data.Ticket
|
||||
import com.dadao.task.data.Workspace
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
private val ticketRoles = listOf(
|
||||
@@ -53,6 +54,9 @@ fun TicketsScreen(
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
var role by remember { mutableStateOf("assigned") }
|
||||
var query by remember { mutableStateOf("") }
|
||||
var appliedQuery by remember { mutableStateOf("") }
|
||||
var refreshTick by remember { mutableStateOf(0) }
|
||||
var tickets by remember { mutableStateOf<List<Ticket>>(emptyList()) }
|
||||
var loading by remember { mutableStateOf(true) }
|
||||
var message by remember { mutableStateOf<String?>(null) }
|
||||
@@ -72,7 +76,7 @@ fun TicketsScreen(
|
||||
|
||||
suspend fun reload() {
|
||||
loading = true
|
||||
runCatching { api.tickets(workspace.id, role) }
|
||||
runCatching { api.tickets(workspace.id, role, appliedQuery.ifBlank { null }) }
|
||||
.onSuccess {
|
||||
tickets = it.tickets
|
||||
message = null
|
||||
@@ -81,7 +85,14 @@ fun TicketsScreen(
|
||||
loading = false
|
||||
}
|
||||
|
||||
LaunchedEffect(role) { reload() }
|
||||
LaunchedEffect(role, appliedQuery, refreshTick) { reload() }
|
||||
|
||||
LaunchedEffect(query) {
|
||||
val target = query.trim()
|
||||
if (target == appliedQuery) return@LaunchedEffect
|
||||
delay(350)
|
||||
appliedQuery = target
|
||||
}
|
||||
|
||||
// 从通知点进来:切到"全部"才看得见那张单,然后清掉待处理标记
|
||||
LaunchedEffect(focusId) {
|
||||
@@ -140,6 +151,14 @@ fun TicketsScreen(
|
||||
creating = true
|
||||
})
|
||||
}
|
||||
Spacer(Modifier.height(10.dp))
|
||||
SearchRefreshBar(
|
||||
value = query,
|
||||
onValue = { query = it },
|
||||
placeholder = "搜工单标题或正文…",
|
||||
refreshing = loading,
|
||||
onRefresh = { refreshTick += 1 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -305,7 +324,10 @@ fun TicketsScreen(
|
||||
.onSuccess {
|
||||
creating = false
|
||||
message = null
|
||||
// 刚开完的单一定在「我发起的」里;如果本来就停在这一栏,
|
||||
// 单靠 role 变化不会触发 reload,补一次刷新
|
||||
role = "requested"
|
||||
refreshTick += 1
|
||||
}
|
||||
.onFailure { fail(it) }
|
||||
}
|
||||
|
||||
@@ -18,12 +18,19 @@ import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextFieldDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -199,6 +206,75 @@ fun GhostButton(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 列表页统一的「搜索 + 刷新」条:任务、工单、知识都用它,免得三处各长一个样。
|
||||
* 刷新按钮独立成型而不是下拉手势——列表里到处是卡片,下拉容易变成滚动。
|
||||
*/
|
||||
@Composable
|
||||
fun SearchRefreshBar(
|
||||
value: String,
|
||||
onValue: (String) -> Unit,
|
||||
placeholder: String,
|
||||
refreshing: Boolean,
|
||||
onRefresh: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = value,
|
||||
onValueChange = onValue,
|
||||
modifier = Modifier.weight(1f),
|
||||
placeholder = {
|
||||
Text(placeholder, style = MaterialTheme.typography.bodySmall, color = InkMuted)
|
||||
},
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
Icons.Filled.Search,
|
||||
contentDescription = null,
|
||||
tint = InkMuted,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
},
|
||||
singleLine = true,
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
colors = TextFieldDefaults.colors(
|
||||
focusedContainerColor = Color.White,
|
||||
unfocusedContainerColor = Color.White,
|
||||
focusedIndicatorColor = Primary,
|
||||
unfocusedIndicatorColor = Line,
|
||||
),
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(Color.White)
|
||||
.border(1.dp, Line, RoundedCornerShape(12.dp))
|
||||
.clickable(enabled = !refreshing) { onRefresh() },
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (refreshing) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(18.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = Primary,
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
Icons.Filled.Refresh,
|
||||
contentDescription = "刷新",
|
||||
tint = InkSoft,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun EmptyState(title: String, desc: String, modifier: Modifier = Modifier) {
|
||||
Column(
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
"""通知提醒:幂等键 + 到期扫描的只读口子
|
||||
|
||||
Revision ID: 0017
|
||||
Revises: 0016
|
||||
Create Date: 2026-11-10
|
||||
|
||||
两件事:
|
||||
|
||||
1. **提醒的幂等键**。到期/逾期提醒由后台循环每几分钟扫一次,扫描本身有
|
||||
advisory lock 保证只有一个进程在跑,但进程重启、时钟抖动、人工重跑都可能
|
||||
让同一条提醒再写一次。`dedupe_key` + 部分唯一索引把"同一件事只提醒一次"
|
||||
交给数据库保证,调用方用 `ON CONFLICT DO NOTHING` 即可。普通通知的
|
||||
`dedupe_key` 为空,不参与唯一约束。
|
||||
|
||||
2. **到期扫描要跨租户列出待办**。提醒是平台级定时任务,扫的时候没有"当前工作区",
|
||||
`item` 的隔离策略会让它一行都看不到。这里照 0016 的做法加一条只在
|
||||
`app.reminder_list = 'on'` 时生效的 **SELECT-only** 策略:扫描器能列出
|
||||
全平台的截止时间,但写通知仍然必须逐个工作区绑定租户,改不了别人的数据。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
from app.roles import APP_ROLE
|
||||
|
||||
revision: str = "0017"
|
||||
down_revision: str = "0016"
|
||||
branch_labels: Sequence[str] | None = None
|
||||
depends_on: Sequence[str] | None = None
|
||||
|
||||
CREATE_REMINDER_POLICY = """
|
||||
CREATE POLICY item_reminder_list ON item
|
||||
FOR SELECT
|
||||
USING (current_setting('app.reminder_list', true) = 'on')
|
||||
"""
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("notification", sa.Column("dedupe_key", sa.String(120), nullable=True))
|
||||
op.create_index(
|
||||
"uq_notification_dedupe",
|
||||
"notification",
|
||||
["workspace_id", "user_id", "dedupe_key"],
|
||||
unique=True,
|
||||
postgresql_where=sa.text("dedupe_key IS NOT NULL"),
|
||||
)
|
||||
|
||||
op.execute("DROP POLICY IF EXISTS item_reminder_list ON item")
|
||||
op.execute(CREATE_REMINDER_POLICY)
|
||||
op.execute(f"GRANT SELECT ON item TO {APP_ROLE}")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP POLICY IF EXISTS item_reminder_list ON item")
|
||||
op.drop_index("uq_notification_dedupe", table_name="notification")
|
||||
op.drop_column("notification", "dedupe_key")
|
||||
+58
-10
@@ -205,6 +205,48 @@ async def _apply_update(
|
||||
return item
|
||||
|
||||
|
||||
async def _notify_status_change(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
ws_id: uuid.UUID,
|
||||
item: Item,
|
||||
previous: str,
|
||||
to_status: str,
|
||||
actor_id: uuid.UUID,
|
||||
actor_name: str,
|
||||
) -> None:
|
||||
"""状态变化的通知。
|
||||
|
||||
"完成"单独一种 kind:收件人一眼能看出是结项,而不是又一次普通的状态流转;
|
||||
其余状态仍走 `task_status_changed`,正文里写清从什么改成了什么。
|
||||
"""
|
||||
if to_status == "done":
|
||||
await notify(
|
||||
session,
|
||||
ws_id=ws_id,
|
||||
user_ids=(item.creator_id, item.assignee_id),
|
||||
kind="task_completed",
|
||||
item_id=item.id,
|
||||
title=f"「{item.title}」已完成",
|
||||
body=f"{actor_name} 把它标记为完成。",
|
||||
actor_id=actor_id,
|
||||
)
|
||||
return
|
||||
await notify(
|
||||
session,
|
||||
ws_id=ws_id,
|
||||
user_ids=(item.creator_id, item.assignee_id),
|
||||
kind="task_status_changed",
|
||||
item_id=item.id,
|
||||
title=f"「{item.title}」变成{TASK_STATUS_CN.get(to_status, to_status)}了",
|
||||
body=(
|
||||
f"{actor_name} 把它从 {TASK_STATUS_CN.get(previous, previous)} "
|
||||
f"改成了 {TASK_STATUS_CN.get(to_status, to_status)}"
|
||||
),
|
||||
actor_id=actor_id,
|
||||
)
|
||||
|
||||
|
||||
# ── 列表 ────────────────────────────────────────────────────────────────
|
||||
def _apply_filters(
|
||||
stmt: Select,
|
||||
@@ -581,6 +623,17 @@ async def batch_status(body: BatchStatusIn, ctx: MemberDep, session: SessionDep)
|
||||
}
|
||||
)
|
||||
continue
|
||||
# 批量改状态以前是"悄悄改完":被指派人/创建人收不到任何通知,
|
||||
# 和单条改状态的行为不一致。这里补上同一条通知。
|
||||
await _notify_status_change(
|
||||
session,
|
||||
ws_id=ctx.ws_id,
|
||||
item=item,
|
||||
previous=previous,
|
||||
to_status=body.status,
|
||||
actor_id=ctx.user.id,
|
||||
actor_name=ctx.user.nickname or "同事",
|
||||
)
|
||||
updated.append({"id": str(item.id), "version": item.version, "unchanged": False})
|
||||
|
||||
return ok(
|
||||
@@ -682,19 +735,14 @@ async def change_status(
|
||||
payload={"from": previous, "to": body.status},
|
||||
)
|
||||
tags = await _tags_of(session, ctx.ws_id, [item.id])
|
||||
await notify(
|
||||
await _notify_status_change(
|
||||
session,
|
||||
ws_id=ctx.ws_id,
|
||||
user_ids=(item.creator_id, item.assignee_id),
|
||||
kind="task_status_changed",
|
||||
item_id=item.id,
|
||||
title=f"「{item.title}」变成{TASK_STATUS_CN.get(body.status, body.status)}了",
|
||||
body=(
|
||||
f"{ctx.user.nickname or '同事'} 把它从 "
|
||||
f"{TASK_STATUS_CN.get(previous, previous)} 改成了 "
|
||||
f"{TASK_STATUS_CN.get(body.status, body.status)}"
|
||||
),
|
||||
item=item,
|
||||
previous=previous,
|
||||
to_status=body.status,
|
||||
actor_id=ctx.user.id,
|
||||
actor_name=ctx.user.nickname or "同事",
|
||||
)
|
||||
return ok(_item_out(item, tags.get(item.id)), message="状态已更新")
|
||||
|
||||
|
||||
@@ -22,13 +22,16 @@ from typing import Annotated, Any
|
||||
|
||||
from fastapi import APIRouter, Query
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import Select, and_, delete, func, or_, select, update
|
||||
from sqlalchemy import Select, and_, delete, func, insert, or_, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..audit import record as audit_record
|
||||
from ..db import bind_tenant
|
||||
from ..deps import MemberDep, SessionDep
|
||||
from ..domain.knowledge import can_publish_role, sidebar_fields, target_status
|
||||
from ..errors import AppError, ErrorCode
|
||||
from ..models import Member, Workspace
|
||||
from ..models_attachment import Attachment
|
||||
from ..models_knowledge import (
|
||||
DOC_TYPES,
|
||||
KNOWLEDGE_STATUSES,
|
||||
@@ -37,11 +40,12 @@ from ..models_knowledge import (
|
||||
KnowledgeCategory,
|
||||
KnowledgeDoc,
|
||||
)
|
||||
from ..models_task import Activity, Item, ItemTag, Tag
|
||||
from ..models_task import Activity, Comment, Item, ItemTag, Tag
|
||||
from ..schemas import clamp_limit, decode_cursor, encode_cursor, ok
|
||||
from ..serializers import item_out, tags_of
|
||||
from ..services.knowledge import create_draft_from_source, find_doc_by_source
|
||||
from ..services.knowledge_index import reindex_document, search_knowledge
|
||||
from ..services.notify import notify, workspace_admins
|
||||
from .attachments import count_for_items, list_for_item
|
||||
|
||||
router = APIRouter(prefix="/workspaces/{ws_id}", tags=["知识库"])
|
||||
@@ -95,6 +99,14 @@ class StatusActionIn(BaseModel):
|
||||
note: str = ""
|
||||
|
||||
|
||||
class BulkMoveIn(BaseModel):
|
||||
item_ids: list[uuid.UUID] = Field(min_length=1, max_length=100)
|
||||
# 不传 = 留在当前工作区;传了就必须是自己是成员的工作区
|
||||
target_workspace_id: uuid.UUID | None = None
|
||||
# 不传 = 移动到"未分类"
|
||||
category_id: uuid.UUID | None = None
|
||||
|
||||
|
||||
# ── 内部工具 ────────────────────────────────────────────────────────────
|
||||
async def _load_doc(
|
||||
session: AsyncSession, ws_id: uuid.UUID, item_id: uuid.UUID, *, include_deleted: bool = False
|
||||
@@ -164,6 +176,11 @@ def _doc_out(doc: KnowledgeDoc) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _row_values(row: Any) -> dict[str, Any]:
|
||||
"""把一行 ORM 对象摊平成可原样 INSERT 的字典(跨租户搬家时用)。"""
|
||||
return {column.name: getattr(row, column.name) for column in row.__table__.columns}
|
||||
|
||||
|
||||
# ── 分类 ────────────────────────────────────────────────────────────────
|
||||
_COLOR_RE = re.compile(r"^#[0-9A-Fa-f]{6}$")
|
||||
|
||||
@@ -653,6 +670,294 @@ async def search(
|
||||
return ok({"query": q, "mode": mode, "items": hits, "has_more": False})
|
||||
|
||||
|
||||
@router.post("/knowledge/bulk-move", summary="批量移动知识到目录 / 其它工作区")
|
||||
async def bulk_move_knowledge(body: BulkMoveIn, ctx: MemberDep, session: SessionDep) -> dict:
|
||||
"""把多条知识一起搬到另一个目录,或搬到另一个工作区的某个目录。
|
||||
|
||||
同一工作区只是改 `knowledge_doc.category_id`;跨工作区是"真的搬家":
|
||||
RLS 下一条 UPDATE 没法既让源租户看得见旧行、又让目标租户放行新行,
|
||||
所以在同一个事务里先读完整快照、按 id 删掉源条目(外键级联清子表),
|
||||
再切到目标租户按同样的 id 写回。失败整体回滚,不会出现"搬了一半"。
|
||||
"""
|
||||
ws_id = ctx.ws_id
|
||||
target_ws = body.target_workspace_id or ws_id
|
||||
item_ids = list(dict.fromkeys(body.item_ids))
|
||||
cross = target_ws != ws_id
|
||||
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(Item, KnowledgeDoc)
|
||||
.join(KnowledgeDoc, KnowledgeDoc.item_id == Item.id)
|
||||
.where(
|
||||
Item.id.in_(item_ids),
|
||||
Item.workspace_id == ws_id,
|
||||
Item.kind == "knowledge",
|
||||
Item.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
).all()
|
||||
found = {item.id for item, _doc in rows}
|
||||
missing = [str(i) for i in item_ids if i not in found]
|
||||
if missing:
|
||||
raise AppError(
|
||||
ErrorCode.KNOWLEDGE_NOT_FOUND,
|
||||
"有知识不存在或已被删除",
|
||||
details={"missing": missing},
|
||||
)
|
||||
|
||||
if cross:
|
||||
# 移出工作区等于把它从团队手里拿走,和删除同一条权限线
|
||||
for item, _doc in rows:
|
||||
if item.creator_id != ctx.user.id and ctx.role not in ("owner", "admin"):
|
||||
raise AppError(
|
||||
ErrorCode.INSUFFICIENT_ROLE,
|
||||
f"「{item.title}」只有作者本人或管理员能移出工作区",
|
||||
)
|
||||
# member 表的策略允许看到"自己的成员记录",所以不用切租户就能确认目标身份
|
||||
target_member = (
|
||||
await session.execute(
|
||||
select(Member).where(
|
||||
Member.workspace_id == target_ws,
|
||||
Member.user_id == ctx.user.id,
|
||||
Member.status == "active",
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if target_member is None or target_member.role not in ("owner", "admin", "member"):
|
||||
raise AppError(ErrorCode.INSUFFICIENT_ROLE, "你不是目标工作区的成员,不能移入")
|
||||
# 目标租户的工作区和分类要切过去才看得见(RLS 只放行当前租户)
|
||||
await bind_tenant(session, target_ws, ctx.user.id)
|
||||
try:
|
||||
target_ws_row = await session.get(Workspace, target_ws)
|
||||
if target_ws_row is None or target_ws_row.deleted_at is not None:
|
||||
raise AppError(ErrorCode.WORKSPACE_NOT_FOUND, "目标工作区不存在")
|
||||
category = await _resolve_category(session, target_ws, body.category_id)
|
||||
finally:
|
||||
await bind_tenant(session, ws_id, ctx.user.id)
|
||||
else:
|
||||
category = await _resolve_category(session, ws_id, body.category_id)
|
||||
|
||||
category_id = category.id if category else None
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
if not cross:
|
||||
await session.execute(
|
||||
update(KnowledgeDoc)
|
||||
.where(KnowledgeDoc.workspace_id == ws_id, KnowledgeDoc.item_id.in_(item_ids))
|
||||
.values(category_id=category_id)
|
||||
)
|
||||
# 分类也算这条知识的"样子"变了,推进 version,免得别处拿着旧版本号撞 4009
|
||||
await session.execute(
|
||||
update(Item)
|
||||
.where(Item.workspace_id == ws_id, Item.id.in_(item_ids))
|
||||
.values(version=Item.version + 1, updated_at=now)
|
||||
)
|
||||
for item, _doc in rows:
|
||||
await _log(
|
||||
session,
|
||||
item,
|
||||
ctx.user.id,
|
||||
"knowledge_moved",
|
||||
{"category_id": str(category_id) if category_id else None},
|
||||
)
|
||||
await audit_record(
|
||||
session,
|
||||
workspace_id=ws_id,
|
||||
actor_user_id=ctx.user.id,
|
||||
action="knowledge.moved",
|
||||
target_type="knowledge_bulk",
|
||||
target_id=str(len(item_ids)),
|
||||
payload={
|
||||
"item_ids": [str(i) for i in item_ids],
|
||||
"category_id": str(category_id) if category_id else None,
|
||||
},
|
||||
)
|
||||
await session.flush()
|
||||
return ok(
|
||||
{
|
||||
"moved": [str(i) for i in item_ids],
|
||||
"workspace_id": str(ws_id),
|
||||
"category_id": str(category_id) if category_id else None,
|
||||
}
|
||||
)
|
||||
|
||||
# ── 跨工作区:先快照,再删,再在目标租户写回 ──────────────────────────
|
||||
item_values = [_row_values(item) for item, _doc in rows]
|
||||
doc_values = [_row_values(doc) for _item, doc in rows]
|
||||
moved = {item.id for item, _doc in rows}
|
||||
|
||||
tag_pairs = (
|
||||
await session.execute(
|
||||
select(ItemTag.item_id, Tag.name, Tag.color)
|
||||
.join(Tag, Tag.id == ItemTag.tag_id)
|
||||
.where(ItemTag.workspace_id == ws_id, ItemTag.item_id.in_(moved))
|
||||
)
|
||||
).all()
|
||||
tags_by_item: dict[uuid.UUID, list[str]] = {}
|
||||
tag_colors: dict[str, str] = {}
|
||||
for item_id, name, color in tag_pairs:
|
||||
tags_by_item.setdefault(item_id, []).append(name)
|
||||
tag_colors.setdefault(name, color or "")
|
||||
|
||||
comment_values = [
|
||||
_row_values(row)
|
||||
for row in (
|
||||
await session.execute(
|
||||
select(Comment).where(Comment.workspace_id == ws_id, Comment.item_id.in_(moved))
|
||||
)
|
||||
).scalars()
|
||||
]
|
||||
activity_values = [
|
||||
_row_values(row)
|
||||
for row in (
|
||||
await session.execute(
|
||||
select(Activity).where(Activity.workspace_id == ws_id, Activity.item_id.in_(moved))
|
||||
)
|
||||
).scalars()
|
||||
]
|
||||
attachment_values = [
|
||||
_row_values(row)
|
||||
for row in (
|
||||
await session.execute(
|
||||
select(Attachment).where(
|
||||
Attachment.workspace_id == ws_id, Attachment.item_id.in_(moved)
|
||||
)
|
||||
)
|
||||
).scalars()
|
||||
]
|
||||
chunk_values = [
|
||||
_row_values(row)
|
||||
for row in (
|
||||
await session.execute(
|
||||
select(Chunk).where(Chunk.workspace_id == ws_id, Chunk.item_id.in_(moved))
|
||||
)
|
||||
).scalars()
|
||||
]
|
||||
|
||||
# 被搬走的知识如果还是本工作区别的知识的"来源",先把引用改成手动来源:
|
||||
# 否则删除源条目时 FK SET NULL 会撞上 source_kind 的 CHECK 约束。
|
||||
await session.execute(
|
||||
update(KnowledgeDoc)
|
||||
.where(
|
||||
KnowledgeDoc.workspace_id == ws_id,
|
||||
KnowledgeDoc.source_item_id.in_(moved),
|
||||
KnowledgeDoc.item_id.not_in(moved),
|
||||
)
|
||||
.values(source_item_id=None, source_kind="manual")
|
||||
)
|
||||
await session.execute(delete(Item).where(Item.workspace_id == ws_id, Item.id.in_(moved)))
|
||||
session.expunge_all()
|
||||
|
||||
await audit_record(
|
||||
session,
|
||||
workspace_id=ws_id,
|
||||
actor_user_id=ctx.user.id,
|
||||
action="knowledge.moved_out",
|
||||
target_type="knowledge_bulk",
|
||||
target_id=str(len(item_ids)),
|
||||
payload={
|
||||
"item_ids": [str(i) for i in item_ids],
|
||||
"target_workspace_id": str(target_ws),
|
||||
"category_id": str(category_id) if category_id else None,
|
||||
},
|
||||
)
|
||||
|
||||
await bind_tenant(session, target_ws, ctx.user.id)
|
||||
existing_tags = {
|
||||
tag.name: tag
|
||||
for tag in (
|
||||
await session.execute(select(Tag).where(Tag.workspace_id == target_ws))
|
||||
).scalars()
|
||||
}
|
||||
created_tags = [
|
||||
Tag(workspace_id=target_ws, name=name, color=color)
|
||||
for name, color in tag_colors.items()
|
||||
if name not in existing_tags
|
||||
]
|
||||
if created_tags:
|
||||
session.add_all(created_tags)
|
||||
await session.flush()
|
||||
tag_ids = {name: tag.id for name, tag in existing_tags.items()}
|
||||
tag_ids.update({tag.name: tag.id for tag in created_tags})
|
||||
|
||||
for values in item_values:
|
||||
values["workspace_id"] = target_ws
|
||||
for values in doc_values:
|
||||
values["workspace_id"] = target_ws
|
||||
values["category_id"] = category_id
|
||||
source_item_id = values.get("source_item_id")
|
||||
if source_item_id is not None and source_item_id not in moved:
|
||||
# 来源留在原工作区,目标工作区里不该留一条跨租户的外键
|
||||
values["source_item_id"] = None
|
||||
values["source_kind"] = "manual"
|
||||
|
||||
await session.execute(insert(Item), item_values)
|
||||
await session.execute(insert(KnowledgeDoc), doc_values)
|
||||
|
||||
item_tag_values = [
|
||||
{
|
||||
"id": uuid.uuid4(),
|
||||
"workspace_id": target_ws,
|
||||
"item_id": item_id,
|
||||
"tag_id": tag_ids[name],
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
"version": 1,
|
||||
}
|
||||
for item_id, names in tags_by_item.items()
|
||||
for name in names
|
||||
]
|
||||
# 子表全部跟着换租户:RLS 的 WITH CHECK 只看新行,漏一个就整单回滚
|
||||
for child_values in (comment_values, activity_values, attachment_values, chunk_values):
|
||||
for row in child_values:
|
||||
row["workspace_id"] = target_ws
|
||||
for table, values in (
|
||||
(ItemTag, item_tag_values),
|
||||
(Comment, comment_values),
|
||||
(Activity, activity_values),
|
||||
(Attachment, attachment_values),
|
||||
(Chunk, chunk_values),
|
||||
):
|
||||
if values:
|
||||
await session.execute(insert(table), values)
|
||||
|
||||
for item_id in item_ids:
|
||||
session.add(
|
||||
Activity(
|
||||
workspace_id=target_ws,
|
||||
item_id=item_id,
|
||||
actor_id=ctx.user.id,
|
||||
action="knowledge_moved",
|
||||
payload={
|
||||
"from_workspace_id": str(ws_id),
|
||||
"category_id": str(category_id) if category_id else None,
|
||||
},
|
||||
)
|
||||
)
|
||||
await session.flush()
|
||||
await audit_record(
|
||||
session,
|
||||
workspace_id=target_ws,
|
||||
actor_user_id=ctx.user.id,
|
||||
action="knowledge.moved_in",
|
||||
target_type="knowledge_bulk",
|
||||
target_id=str(len(item_ids)),
|
||||
payload={
|
||||
"item_ids": [str(i) for i in item_ids],
|
||||
"from_workspace_id": str(ws_id),
|
||||
"category_id": str(category_id) if category_id else None,
|
||||
},
|
||||
)
|
||||
await bind_tenant(session, ws_id, ctx.user.id)
|
||||
return ok(
|
||||
{
|
||||
"moved": [str(i) for i in item_ids],
|
||||
"workspace_id": str(target_ws),
|
||||
"category_id": str(category_id) if category_id else None,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/knowledge/{item_id}", summary="知识详情")
|
||||
async def get_knowledge(item_id: uuid.UUID, ctx: MemberDep, session: SessionDep) -> dict:
|
||||
item, doc = await _load_doc(session, ctx.ws_id, item_id)
|
||||
@@ -721,6 +1026,17 @@ async def create_knowledge(body: KnowledgeIn, ctx: MemberDep, session: SessionDe
|
||||
await session.flush()
|
||||
await _log(session, item, ctx.user.id, "knowledge_created", {"generated_by": "human"})
|
||||
await _set_tags_for(session, ws_id, item, body.tag_names)
|
||||
# 知识是团队资产:新建草稿先告诉能发布的人,别让草稿在列表里躺到没人发现。
|
||||
await notify(
|
||||
session,
|
||||
ws_id=ws_id,
|
||||
user_ids=await workspace_admins(session, ws_id),
|
||||
kind="knowledge_draft_created",
|
||||
item_id=item.id,
|
||||
title=f"新的知识草稿:{item.title}",
|
||||
body=f"{ctx.user.nickname or '同事'} 新建了一条知识,核对后可以发布。",
|
||||
actor_id=ctx.user.id,
|
||||
)
|
||||
await audit_record(
|
||||
session,
|
||||
workspace_id=ws_id,
|
||||
@@ -900,6 +1216,42 @@ async def change_status(
|
||||
target_id=str(item.id),
|
||||
payload={"to": to_status},
|
||||
)
|
||||
# 知识的状态变化要让相关的人知道:提交审议找管理员核对,
|
||||
# 发布/归档则回告作者——否则作者不知道自己的草稿已经被团队采纳。
|
||||
actor_name = ctx.user.nickname or "同事"
|
||||
if body.action == "submit_review":
|
||||
await notify(
|
||||
session,
|
||||
ws_id=ws_id,
|
||||
user_ids=await workspace_admins(session, ws_id),
|
||||
kind="knowledge_review_requested",
|
||||
item_id=item.id,
|
||||
title=f"待核对:{item.title}",
|
||||
body=f"{actor_name} 提交了一份知识,核对后可以发布。",
|
||||
actor_id=ctx.user.id,
|
||||
)
|
||||
elif body.action == "publish":
|
||||
await notify(
|
||||
session,
|
||||
ws_id=ws_id,
|
||||
user_ids=(doc.author_id,),
|
||||
kind="knowledge_published",
|
||||
item_id=item.id,
|
||||
title=f"「{item.title}」已发布",
|
||||
body=f"{actor_name} 发布了这份知识,现在可以被团队检索到。",
|
||||
actor_id=ctx.user.id,
|
||||
)
|
||||
elif body.action == "archive":
|
||||
await notify(
|
||||
session,
|
||||
ws_id=ws_id,
|
||||
user_ids=(doc.author_id,),
|
||||
kind="knowledge_archived",
|
||||
item_id=item.id,
|
||||
title=f"「{item.title}」已归档",
|
||||
body=f"{actor_name} 把它归档了,检索不再返回它。",
|
||||
actor_id=ctx.user.id,
|
||||
)
|
||||
await session.flush()
|
||||
if body.action == "publish":
|
||||
# 发布后立刻建索引:检索只认 published,不在这里建就要等定时任务,
|
||||
@@ -966,6 +1318,16 @@ async def promote_to_knowledge(
|
||||
await _log(
|
||||
session, doc_item, ctx.user.id, "knowledge_created", {"source_item_id": str(source.id)}
|
||||
)
|
||||
await notify(
|
||||
session,
|
||||
ws_id=ws_id,
|
||||
user_ids=await workspace_admins(session, ws_id),
|
||||
kind="knowledge_draft_created",
|
||||
item_id=doc_item.id,
|
||||
title=f"从事项沉淀了知识:{doc_item.title}",
|
||||
body=f"{ctx.user.nickname or '同事'} 把「{source.title}」沉淀成草稿,核对后可以发布。",
|
||||
actor_id=ctx.user.id,
|
||||
)
|
||||
await audit_record(
|
||||
session,
|
||||
workspace_id=ws_id,
|
||||
|
||||
@@ -601,6 +601,7 @@ async def list_tickets(
|
||||
session: SessionDep,
|
||||
role: Annotated[str, Query(description="assigned|requested|all")] = "assigned",
|
||||
status: Annotated[list[str] | None, Query(description="按状态过滤,可重复")] = None,
|
||||
q: Annotated[str | None, Query(description="搜标题或正文")] = None,
|
||||
limit: Annotated[int, Query(ge=1, le=100)] = 20,
|
||||
cursor: Annotated[str | None, Query()] = None,
|
||||
) -> dict:
|
||||
@@ -622,6 +623,11 @@ async def list_tickets(
|
||||
if status:
|
||||
stmt = stmt.where(Item.status.in_(status))
|
||||
|
||||
if q and q.strip():
|
||||
# 与知识列表同一套模糊匹配:手机上找一张单,标题记不清时正文里的关键词也能命中
|
||||
like = f"%{q.strip()}%"
|
||||
stmt = stmt.where(or_(Item.title.ilike(like), Item.body_md.ilike(like)))
|
||||
|
||||
stmt = stmt.order_by(Item.updated_at.desc(), Item.id.desc()).limit(limit + 1)
|
||||
if parsed_cursor is not None:
|
||||
stmt = stmt.where(
|
||||
@@ -767,11 +773,14 @@ async def delete_link(
|
||||
|
||||
|
||||
# -- 通知 ---------------------------------------------------------------
|
||||
def _notification_out(n: Notification, item_title: str | None = None) -> dict[str, Any]:
|
||||
def _notification_out(
|
||||
n: Notification, item_title: str | None = None, item_kind: str | None = None
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"id": str(n.id),
|
||||
"kind": n.kind,
|
||||
"item_id": str(n.item_id) if n.item_id else None,
|
||||
"item_kind": item_kind,
|
||||
"item_title": item_title,
|
||||
"title": n.title,
|
||||
"body": n.body,
|
||||
@@ -813,18 +822,27 @@ async def list_notifications(
|
||||
rows = list(rows[:limit])
|
||||
|
||||
titles: dict[uuid.UUID, str] = {}
|
||||
kinds: dict[uuid.UUID, str] = {}
|
||||
item_ids = [n.item_id for n in rows if n.item_id]
|
||||
if item_ids:
|
||||
for item_id, title in (
|
||||
await session.execute(select(Item.id, Item.title).where(Item.id.in_(item_ids)))
|
||||
for item_id, title, kind in (
|
||||
await session.execute(
|
||||
select(Item.id, Item.title, Item.kind).where(Item.id.in_(item_ids))
|
||||
)
|
||||
).all():
|
||||
titles[item_id] = title
|
||||
kinds[item_id] = kind
|
||||
|
||||
next_cursor = encode_cursor(rows[-1].created_at, rows[-1].id) if has_more and rows else None
|
||||
return ok(
|
||||
{
|
||||
"notifications": [
|
||||
_notification_out(n, titles.get(n.item_id) if n.item_id else None) for n in rows
|
||||
_notification_out(
|
||||
n,
|
||||
titles.get(n.item_id) if n.item_id else None,
|
||||
kinds.get(n.item_id) if n.item_id else None,
|
||||
)
|
||||
for n in rows
|
||||
],
|
||||
"next_cursor": next_cursor,
|
||||
"has_more": has_more,
|
||||
|
||||
@@ -112,6 +112,14 @@ class Settings(BaseSettings):
|
||||
# 30 分钟够一次阅读/编辑,过期后刷新页面会重新签发。
|
||||
attachment_url_ttl_seconds: int = 1800
|
||||
|
||||
# ── 站内提醒 ──────────────────────────────────────────
|
||||
# 到期/逾期提醒是后台轮询,不引入额外组件(见 services/reminders.py)。
|
||||
# 间隔决定"最晚多久能收到提醒",5 分钟对截止时间以小时计的任务足够。
|
||||
reminders_enabled: bool = True
|
||||
reminder_interval_seconds: int = 300
|
||||
# 提前多久提醒"快到期"。默认 24 小时;改成 0 等于只提醒逾期。
|
||||
reminder_due_soon_hours: int = 24
|
||||
|
||||
# ── 知识检索(M3)──────────────────────────────────────
|
||||
# 切片大小与重叠:中文一段 600 字大约 400~600 token,够表达一个完整片段;
|
||||
# 重叠 80 字是为了不把跨段的话切断(检索命中时前后文还读得懂)。
|
||||
|
||||
@@ -32,6 +32,7 @@ from .db import SessionFactory, current_app_role, engine
|
||||
from .errors import AppError, ErrorCode, http_status_for
|
||||
from .logging_setup import get_logger, request_id_ctx, setup_logging
|
||||
from .middleware import IdempotencyMiddleware, RequestContextMiddleware, error_body
|
||||
from .services import reminders
|
||||
from .services.wechat_bot import manager as wechat_manager
|
||||
|
||||
settings = get_settings()
|
||||
@@ -53,7 +54,10 @@ async def lifespan(app: FastAPI):
|
||||
)
|
||||
# 已登录的微信通道在进程启动时接上,不然重启之后消息就静默收不到了。
|
||||
await wechat_manager.start_all()
|
||||
# 到期/逾期提醒:多 worker 下靠 advisory lock 保证只有一个进程在扫。
|
||||
reminders.start()
|
||||
yield
|
||||
await reminders.shutdown()
|
||||
await wechat_manager.shutdown()
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ from sqlalchemy import (
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
func,
|
||||
text,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import UUID as PgUUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
@@ -121,6 +122,16 @@ class Notification(Base, Timestamps):
|
||||
__table_args__ = (
|
||||
Index("ix_notification_ws_user_read", "workspace_id", "user_id", "read_at"),
|
||||
Index("ix_notification_ws_created", "workspace_id", "created_at"),
|
||||
# 到期/逾期提醒由后台循环生成,可能被重复触发;带 dedupe_key 的写入用
|
||||
# ON CONFLICT DO NOTHING,把"同一件事只提醒一次"交给数据库保证。
|
||||
Index(
|
||||
"uq_notification_dedupe",
|
||||
"workspace_id",
|
||||
"user_id",
|
||||
"dedupe_key",
|
||||
unique=True,
|
||||
postgresql_where=text("dedupe_key IS NOT NULL"),
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(PgUUID(as_uuid=True), primary_key=True, default=new_uuid)
|
||||
@@ -137,6 +148,9 @@ class Notification(Base, Timestamps):
|
||||
title: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
body: Mapped[str] = mapped_column(String(300), nullable=False, default="")
|
||||
read_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
# 提醒类通知的幂等键(如 task_due_soon:<item>:<due_at>)。
|
||||
# 普通通知为空,不参与唯一约束。
|
||||
dedupe_key: Mapped[str | None] = mapped_column(String(120))
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
|
||||
@@ -15,8 +15,11 @@ from __future__ import annotations
|
||||
import uuid
|
||||
from collections.abc import Iterable
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..models import Member
|
||||
from ..models_ticket import Notification
|
||||
|
||||
TICKET_STATUS_CN = {
|
||||
@@ -48,6 +51,7 @@ async def notify(
|
||||
title: str,
|
||||
body: str = "",
|
||||
actor_id: uuid.UUID | None = None,
|
||||
dedupe_key: str | None = None,
|
||||
) -> int:
|
||||
"""给若干人各写一条通知,返回实际写入条数。
|
||||
|
||||
@@ -67,7 +71,55 @@ async def notify(
|
||||
item_id=item_id,
|
||||
title=title[:120],
|
||||
body=body[:300],
|
||||
dedupe_key=dedupe_key,
|
||||
)
|
||||
)
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
async def notify_once(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
ws_id: uuid.UUID,
|
||||
user_id: uuid.UUID | None,
|
||||
kind: str,
|
||||
item_id: uuid.UUID | None,
|
||||
title: str,
|
||||
body: str = "",
|
||||
dedupe_key: str,
|
||||
actor_id: uuid.UUID | None = None,
|
||||
) -> bool:
|
||||
"""写一条带幂等键的通知;已经写过就跳过,返回是否真的写入。
|
||||
|
||||
后台提醒循环会反复扫同一批任务,靠 `dedupe_key` 的唯一索引把重复挡在数据库层,
|
||||
比"先查再插"少一次竞态。提醒本身不是业务事实,丢了不致命,重复才烦人。
|
||||
"""
|
||||
if user_id is None or user_id == actor_id:
|
||||
return False
|
||||
result = await session.execute(
|
||||
pg_insert(Notification)
|
||||
.values(
|
||||
workspace_id=ws_id,
|
||||
user_id=user_id,
|
||||
kind=kind,
|
||||
item_id=item_id,
|
||||
title=title[:120],
|
||||
body=body[:300],
|
||||
dedupe_key=dedupe_key,
|
||||
)
|
||||
.on_conflict_do_nothing()
|
||||
)
|
||||
return result.rowcount > 0
|
||||
|
||||
|
||||
async def workspace_admins(session: AsyncSession, ws_id: uuid.UUID) -> list[uuid.UUID]:
|
||||
"""工作区里能"发布/把关"的人:owner + admin,只算正常成员。"""
|
||||
rows = await session.execute(
|
||||
select(Member.user_id).where(
|
||||
Member.workspace_id == ws_id,
|
||||
Member.status == "active",
|
||||
Member.role.in_(("owner", "admin")),
|
||||
)
|
||||
)
|
||||
return [row[0] for row in rows]
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
"""到期与逾期提醒。
|
||||
|
||||
为什么是"轮询 + 数据库租约"而不是引入 Celery/APScheduler:
|
||||
|
||||
1. 生产是 gunicorn 多 worker,进程内定时器会各跑一遍,通知就重复了。
|
||||
`pg_try_advisory_lock` 是现成的单实例租约,和微信机器人用的是同一套做法,
|
||||
不额外增加运维组件。
|
||||
2. 提醒的准确度要求是"几分钟内",不是秒级;一轮扫描就是一条按
|
||||
`(workspace_id, status, due_at)` 索引的查询,代价可以忽略。
|
||||
3. 重复触发用 `notification.dedupe_key` 兜底:扫描器重启、时钟抖动、
|
||||
人工补跑都不会把同一条提醒写两遍。
|
||||
|
||||
扫描本身跨租户(平台级任务),所以先在 `app.reminder_list = 'on'` 的只读口子下
|
||||
把候选任务列出来,再逐个工作区绑定租户写通知——读可以跨,写必须归位。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import math
|
||||
import uuid
|
||||
from contextlib import suppress
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from sqlalchemy import select, text
|
||||
|
||||
from ..config import get_settings
|
||||
from ..db import SessionFactory, bind_tenant, engine
|
||||
from ..logging_setup import get_logger
|
||||
from ..models_task import Item
|
||||
from .notify import notify_once
|
||||
|
||||
logger = get_logger("app.reminders")
|
||||
|
||||
_LOCK_NAMESPACE = "dadao.reminders"
|
||||
# 一轮最多处理这么多条:正常量级远小于它;真到了这个量级,说明该拆任务而不是加锁。
|
||||
_SCAN_LIMIT = 500
|
||||
|
||||
_task: asyncio.Task[None] | None = None
|
||||
_stopping = asyncio.Event()
|
||||
|
||||
|
||||
async def _candidates() -> list[dict[str, Any]]:
|
||||
"""列出所有"还没完成、有截止时间、已经进入提醒窗口"的事项。"""
|
||||
settings = get_settings()
|
||||
now = datetime.now(timezone.utc)
|
||||
horizon = now + timedelta(hours=max(1, settings.reminder_due_soon_hours))
|
||||
async with SessionFactory() as session:
|
||||
await session.execute(text("SELECT set_config('app.reminder_list', 'on', true)"))
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(
|
||||
Item.id,
|
||||
Item.workspace_id,
|
||||
Item.title,
|
||||
Item.status,
|
||||
Item.due_at,
|
||||
Item.creator_id,
|
||||
Item.assignee_id,
|
||||
)
|
||||
.where(
|
||||
Item.deleted_at.is_(None),
|
||||
Item.kind != "knowledge",
|
||||
Item.status.not_in(("done", "canceled")),
|
||||
Item.due_at.is_not(None),
|
||||
Item.due_at <= horizon,
|
||||
)
|
||||
.order_by(Item.due_at)
|
||||
.limit(_SCAN_LIMIT)
|
||||
)
|
||||
).all()
|
||||
return [dict(row._mapping) for row in rows]
|
||||
|
||||
|
||||
def _local(dt: datetime) -> datetime:
|
||||
return dt.astimezone(ZoneInfo(get_settings().default_timezone))
|
||||
|
||||
|
||||
def _due_soon_message(row: dict[str, Any], now: datetime) -> tuple[str, str]:
|
||||
due_at = row["due_at"]
|
||||
hours = max(1, math.ceil((due_at - now).total_seconds() / 3600))
|
||||
return (
|
||||
f"「{row['title']}」{hours} 小时内到期",
|
||||
f"截止时间 {_local(due_at):%m-%d %H:%M},请尽快处理。",
|
||||
)
|
||||
|
||||
|
||||
def _overdue_message(row: dict[str, Any], now: datetime) -> tuple[str, str]:
|
||||
due_at = row["due_at"]
|
||||
late = now - due_at
|
||||
days = late.days
|
||||
if days >= 1:
|
||||
tail = f"已经逾期 {days} 天"
|
||||
else:
|
||||
tail = f"已经逾期 {max(1, int(late.total_seconds() // 3600))} 小时"
|
||||
return (
|
||||
f"「{row['title']}」已逾期",
|
||||
f"原定 {_local(due_at):%m-%d %H:%M} 完成,{tail}。",
|
||||
)
|
||||
|
||||
|
||||
async def _remind_workspace(ws_id: uuid.UUID, rows: list[dict[str, Any]], now: datetime) -> int:
|
||||
sent = 0
|
||||
async with SessionFactory() as session, session.begin():
|
||||
await bind_tenant(session, ws_id, None)
|
||||
for row in rows:
|
||||
recipient = row["assignee_id"] or row["creator_id"]
|
||||
if recipient is None:
|
||||
continue
|
||||
due_at: datetime = row["due_at"]
|
||||
if due_at > now:
|
||||
title, body = _due_soon_message(row, now)
|
||||
kind = "task_due_soon"
|
||||
dedupe = f"task_due_soon:{row['id']}:{due_at.isoformat()}"
|
||||
else:
|
||||
title, body = _overdue_message(row, now)
|
||||
kind = "task_overdue"
|
||||
today = _local(now).date().isoformat()
|
||||
dedupe = f"task_overdue:{row['id']}:{due_at.isoformat()}:{today}"
|
||||
if await notify_once(
|
||||
session,
|
||||
ws_id=ws_id,
|
||||
user_id=recipient,
|
||||
kind=kind,
|
||||
item_id=row["id"],
|
||||
title=title,
|
||||
body=body,
|
||||
dedupe_key=dedupe,
|
||||
):
|
||||
sent += 1
|
||||
return sent
|
||||
|
||||
|
||||
async def tick() -> dict[str, int]:
|
||||
"""扫一轮。返回本轮的统计,供日志与测试使用。"""
|
||||
settings = get_settings()
|
||||
if not settings.reminders_enabled:
|
||||
return {"scanned": 0, "sent": 0, "skipped": 1}
|
||||
|
||||
lock_conn = await engine.connect()
|
||||
try:
|
||||
got = (
|
||||
await lock_conn.execute(
|
||||
text("SELECT pg_try_advisory_lock(hashtext(:ns))"), {"ns": _LOCK_NAMESPACE}
|
||||
)
|
||||
).scalar()
|
||||
if not got:
|
||||
# 另一个 worker 正在扫,这一轮让给它。
|
||||
return {"scanned": 0, "sent": 0, "skipped": 1}
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
rows = await _candidates()
|
||||
by_ws: dict[uuid.UUID, list[dict[str, Any]]] = {}
|
||||
for row in rows:
|
||||
by_ws.setdefault(row["workspace_id"], []).append(row)
|
||||
|
||||
sent = 0
|
||||
for ws_id, group in by_ws.items():
|
||||
sent += await _remind_workspace(ws_id, group, now)
|
||||
if sent:
|
||||
logger.info(
|
||||
"到期提醒已发送",
|
||||
extra={"extra_fields": {"scanned": len(rows), "sent": sent}},
|
||||
)
|
||||
return {"scanned": len(rows), "sent": sent, "skipped": 0}
|
||||
finally:
|
||||
# session 级 advisory lock 挂在连接上;解锁后连接才能安全回到池里。
|
||||
# 连接已断时解锁失败无需处理,连接本身也回不了池。
|
||||
with suppress(Exception):
|
||||
await lock_conn.execute(
|
||||
text("SELECT pg_advisory_unlock(hashtext(:ns))"), {"ns": _LOCK_NAMESPACE}
|
||||
)
|
||||
await lock_conn.close()
|
||||
|
||||
|
||||
async def _loop() -> None:
|
||||
settings = get_settings()
|
||||
while not _stopping.is_set():
|
||||
try:
|
||||
await tick()
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception: # pragma: no cover - 单轮失败不该把循环打死
|
||||
logger.exception("到期提醒扫描失败")
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
_stopping.wait(), timeout=max(5, settings.reminder_interval_seconds)
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
continue
|
||||
|
||||
|
||||
def start() -> None:
|
||||
"""启动后台提醒循环。测试环境与关闭开关时直接不启动。"""
|
||||
global _task
|
||||
settings = get_settings()
|
||||
if settings.env == "test" or not settings.reminders_enabled:
|
||||
return
|
||||
if _task is not None and not _task.done():
|
||||
return
|
||||
_stopping.clear()
|
||||
_task = asyncio.create_task(_loop(), name="dadao-reminders")
|
||||
|
||||
|
||||
async def shutdown() -> None:
|
||||
global _task
|
||||
_stopping.set()
|
||||
if _task is None:
|
||||
return
|
||||
_task.cancel()
|
||||
with suppress(asyncio.CancelledError, Exception):
|
||||
await _task
|
||||
_task = None
|
||||
@@ -538,3 +538,227 @@ async def test_tags_endpoint_counts_documents(client: AsyncClient) -> None:
|
||||
)
|
||||
counts = {row["name"]: row["count"] for row in got["items"]}
|
||||
assert counts == {"发布": 2, "回滚": 1}
|
||||
|
||||
|
||||
async def _category(
|
||||
client: AsyncClient, user: dict[str, Any], ws_id: str, name: str
|
||||
) -> dict[str, Any]:
|
||||
return unwrap(
|
||||
await client.post(
|
||||
f"{V1}/workspaces/{ws_id}/knowledge/categories",
|
||||
json={"name": name},
|
||||
headers=user["headers"],
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def _note(
|
||||
client: AsyncClient, user: dict[str, Any], ws_id: str, title: str, **extra: Any
|
||||
) -> dict[str, Any]:
|
||||
return unwrap(
|
||||
await client.post(
|
||||
f"{V1}/workspaces/{ws_id}/knowledge",
|
||||
json={"title": title, "doc_type": "note", **extra},
|
||||
headers=user["headers"],
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def _bulk_move(
|
||||
client: AsyncClient, user: dict[str, Any], ws_id: str, body: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
return unwrap(
|
||||
await client.post(
|
||||
f"{V1}/workspaces/{ws_id}/knowledge/bulk-move",
|
||||
json=body,
|
||||
headers=user["headers"],
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_move_into_category_of_same_workspace(client: AsyncClient) -> None:
|
||||
owner = await login(client)
|
||||
ws = await create_workspace(client, owner, "整理知识")
|
||||
cat = await _category(client, owner, ws["id"], "制度")
|
||||
first = await _note(client, owner, ws["id"], "报销要贴发票原件")
|
||||
second = await _note(client, owner, ws["id"], "请假提前一天提")
|
||||
|
||||
moved = await _bulk_move(
|
||||
client,
|
||||
owner,
|
||||
ws["id"],
|
||||
{"item_ids": [first["id"], second["id"]], "category_id": cat["id"]},
|
||||
)
|
||||
assert sorted(moved["moved"]) == sorted([first["id"], second["id"]])
|
||||
assert moved["workspace_id"] == ws["id"]
|
||||
assert moved["category_id"] == cat["id"]
|
||||
|
||||
listed = unwrap(
|
||||
await client.get(
|
||||
f"{V1}/workspaces/{ws['id']}/knowledge",
|
||||
params={"category_id": cat["id"]},
|
||||
headers=owner["headers"],
|
||||
)
|
||||
)
|
||||
assert sorted(i["id"] for i in listed["items"]) == sorted([first["id"], second["id"]])
|
||||
assert all(i["category"]["id"] == cat["id"] for i in listed["items"])
|
||||
|
||||
# 不动目录也照样能移(移动到"未分类")
|
||||
uncategorized = await _bulk_move(client, owner, ws["id"], {"item_ids": [first["id"]]})
|
||||
assert uncategorized["category_id"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_move_across_workspaces_keeps_tags_and_children(client: AsyncClient) -> None:
|
||||
owner = await login(client)
|
||||
src = await create_workspace(client, owner, "来源库")
|
||||
dst = await create_workspace(client, owner, "目标库")
|
||||
cat = await _category(client, owner, dst["id"], "运维")
|
||||
doc = await _note(client, owner, src["id"], "重启网关的顺序", tag_names=["运维", "网关"])
|
||||
|
||||
moved = await _bulk_move(
|
||||
client,
|
||||
owner,
|
||||
src["id"],
|
||||
{
|
||||
"item_ids": [doc["id"]],
|
||||
"target_workspace_id": dst["id"],
|
||||
"category_id": cat["id"],
|
||||
},
|
||||
)
|
||||
assert moved["workspace_id"] == dst["id"]
|
||||
assert moved["category_id"] == cat["id"]
|
||||
|
||||
gone = await client.get(
|
||||
f"{V1}/workspaces/{src['id']}/knowledge/{doc['id']}", headers=owner["headers"]
|
||||
)
|
||||
assert gone.json()["code"] == 7001
|
||||
|
||||
got = unwrap(
|
||||
await client.get(
|
||||
f"{V1}/workspaces/{dst['id']}/knowledge/{doc['id']}", headers=owner["headers"]
|
||||
)
|
||||
)
|
||||
assert got["id"] == doc["id"]
|
||||
assert got["knowledge"]["category_id"] == cat["id"]
|
||||
assert sorted(t["name"] for t in got["tags"]) == ["网关", "运维"]
|
||||
assert got["knowledge"]["source_kind"] == "manual"
|
||||
|
||||
# 标签本身也在目标工作区里建好了,不是只在关联表里挂了个空壳
|
||||
tags = unwrap(
|
||||
await client.get(f"{V1}/workspaces/{dst['id']}/knowledge/tags", headers=owner["headers"])
|
||||
)
|
||||
assert {row["name"] for row in tags["items"]} == {"运维", "网关"}
|
||||
|
||||
# 历史事件也搬过来了,不然详情页会像凭空出现的一篇
|
||||
actions = {e["action"] for e in got["activities"]}
|
||||
assert {"knowledge_created", "knowledge_moved"} <= actions
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_move_ticket_source_becomes_manual(client: AsyncClient) -> None:
|
||||
owner = await login(client)
|
||||
src = await create_workspace(client, owner, "有来源的库")
|
||||
dst = await create_workspace(client, owner, "接手的库")
|
||||
helper = await _member(client, owner, src)
|
||||
task = await _task(client, owner, src["id"], "网关老是超时")
|
||||
ticket = await _finish_ticket(client, owner, src["id"], helper, task)
|
||||
# 验收通过时系统已经自动沉淀了一份草稿,直接拿它
|
||||
listed = unwrap(
|
||||
await client.get(
|
||||
f"{V1}/workspaces/{src['id']}/knowledge",
|
||||
params={"source_item_id": ticket["id"]},
|
||||
headers=owner["headers"],
|
||||
)
|
||||
)
|
||||
doc = listed["items"][0]
|
||||
|
||||
moved = await _bulk_move(
|
||||
client,
|
||||
owner,
|
||||
src["id"],
|
||||
{"item_ids": [doc["id"]], "target_workspace_id": dst["id"]},
|
||||
)
|
||||
assert moved["moved"] == [doc["id"]]
|
||||
|
||||
got = unwrap(
|
||||
await client.get(
|
||||
f"{V1}/workspaces/{dst['id']}/knowledge/{doc['id']}", headers=owner["headers"]
|
||||
)
|
||||
)
|
||||
# 目标库里没有那张工单,来源必须降级成手动,否则指向一个看不见的 id
|
||||
assert got["knowledge"]["source_kind"] == "manual"
|
||||
assert got["knowledge"]["source_item_id"] is None
|
||||
assert got["source"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_move_requires_membership_in_target(client: AsyncClient) -> None:
|
||||
owner = await login(client)
|
||||
src = await create_workspace(client, owner, "自己的库")
|
||||
stranger = await login(client)
|
||||
dst = await create_workspace(client, stranger, "别人的库")
|
||||
doc = await _note(client, owner, src["id"], "我的笔记")
|
||||
|
||||
resp = await client.post(
|
||||
f"{V1}/workspaces/{src['id']}/knowledge/bulk-move",
|
||||
json={"item_ids": [doc["id"]], "target_workspace_id": dst["id"]},
|
||||
headers=owner["headers"],
|
||||
)
|
||||
assert resp.json()["code"] == 3004
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_move_rejects_foreign_category_and_missing_docs(client: AsyncClient) -> None:
|
||||
owner = await login(client)
|
||||
src = await create_workspace(client, owner, "甲库")
|
||||
dst = await create_workspace(client, owner, "乙库")
|
||||
src_cat = await _category(client, owner, src["id"], "甲的分类")
|
||||
doc = await _note(client, owner, src["id"], "一篇")
|
||||
|
||||
foreign = await client.post(
|
||||
f"{V1}/workspaces/{src['id']}/knowledge/bulk-move",
|
||||
json={
|
||||
"item_ids": [doc["id"]],
|
||||
"target_workspace_id": dst["id"],
|
||||
"category_id": src_cat["id"],
|
||||
},
|
||||
headers=owner["headers"],
|
||||
)
|
||||
assert foreign.json()["code"] == 7006
|
||||
|
||||
missing = await client.post(
|
||||
f"{V1}/workspaces/{src['id']}/knowledge/bulk-move",
|
||||
json={"item_ids": ["00000000-0000-0000-0000-000000000001"]},
|
||||
headers=owner["headers"],
|
||||
)
|
||||
assert missing.json()["code"] == 7001
|
||||
assert missing.json()["data"]["missing"] == ["00000000-0000-0000-0000-000000000001"]
|
||||
|
||||
# 移出后源库再移一次要报 7001,而不是静默成功
|
||||
await _bulk_move(
|
||||
client, owner, src["id"], {"item_ids": [doc["id"]], "target_workspace_id": dst["id"]}
|
||||
)
|
||||
again = await client.post(
|
||||
f"{V1}/workspaces/{src['id']}/knowledge/bulk-move",
|
||||
json={"item_ids": [doc["id"]]},
|
||||
headers=owner["headers"],
|
||||
)
|
||||
assert again.json()["code"] == 7001
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_move_cannot_touch_another_tenants_knowledge(client: AsyncClient) -> None:
|
||||
owner = await login(client)
|
||||
ws = await create_workspace(client, owner, "甲的工作区")
|
||||
doc = await _note(client, owner, ws["id"], "甲的秘密")
|
||||
outsider = await login(client)
|
||||
own = await create_workspace(client, outsider, "乙的工作区")
|
||||
|
||||
resp = await client.post(
|
||||
f"{V1}/workspaces/{own['id']}/knowledge/bulk-move",
|
||||
json={"item_ids": [doc["id"]]},
|
||||
headers=outsider["headers"],
|
||||
)
|
||||
assert resp.json()["code"] == 7001
|
||||
|
||||
@@ -11,11 +11,14 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from app.services import reminders
|
||||
|
||||
from .conftest import create_workspace, login, unwrap
|
||||
|
||||
V1 = "/api/v1"
|
||||
@@ -269,3 +272,223 @@ async def test_unread_count_and_read_all(client: AsyncClient) -> None:
|
||||
)
|
||||
)
|
||||
assert count["unread"] == 0
|
||||
|
||||
|
||||
async def _set_status(
|
||||
client: AsyncClient, user: dict[str, Any], ws_id: str, item: dict, status: str
|
||||
) -> dict:
|
||||
return unwrap(
|
||||
await client.post(
|
||||
f"{V1}/workspaces/{ws_id}/items/{item['id']}/status",
|
||||
json={"status": status, "version": item["version"]},
|
||||
headers=user["headers"],
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_task_completion_notifies_the_creator(client: AsyncClient) -> None:
|
||||
"""派出去的活被干完了,派活的人要知道——但干活的人不该收到"你完成了"。"""
|
||||
owner = await login(client)
|
||||
ws = await create_workspace(client, owner, "完成通知")
|
||||
helper = await _member(client, owner, ws, "member")
|
||||
item = await _task_assigned_to(client, owner, ws["id"], helper["user_id"], "季度盘点")
|
||||
|
||||
await _set_status(client, helper, ws["id"], item, "done")
|
||||
|
||||
owner_inbox = await _inbox(client, owner, ws["id"])
|
||||
completed = [n for n in owner_inbox if n["kind"] == "task_completed"]
|
||||
assert len(completed) == 1
|
||||
assert completed[0]["item_id"] == item["id"]
|
||||
assert completed[0]["item_kind"] == "task"
|
||||
assert "已完成" in completed[0]["title"]
|
||||
assert not [n for n in await _inbox(client, helper, ws["id"]) if n["kind"] == "task_completed"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_batch_status_sends_the_same_notifications(client: AsyncClient) -> None:
|
||||
"""看板批量拖拽不能静默:拖 3 条完成,创建人就该收到 3 条完成通知。"""
|
||||
owner = await login(client)
|
||||
ws = await create_workspace(client, owner, "批量完成通知")
|
||||
helper = await _member(client, owner, ws, "member")
|
||||
items = [
|
||||
await _task_assigned_to(client, owner, ws["id"], helper["user_id"], f"批量任务 {i}")
|
||||
for i in range(3)
|
||||
]
|
||||
|
||||
unwrap(
|
||||
await client.post(
|
||||
f"{V1}/workspaces/{ws['id']}/items/batch-status",
|
||||
json={
|
||||
"status": "done",
|
||||
"items": [{"id": item["id"], "version": item["version"]} for item in items],
|
||||
},
|
||||
headers=helper["headers"],
|
||||
)
|
||||
)
|
||||
|
||||
owner_inbox = await _inbox(client, owner, ws["id"])
|
||||
completed = {n["item_id"] for n in owner_inbox if n["kind"] == "task_completed"}
|
||||
assert completed == {item["id"] for item in items}
|
||||
assert not [n for n in await _inbox(client, helper, ws["id"]) if n["kind"] == "task_completed"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_knowledge_notifications_follow_the_flow(client: AsyncClient) -> None:
|
||||
"""知识库的四个节点都要有人知道:新草稿、待核对、已发布、已归档。"""
|
||||
owner = await login(client)
|
||||
ws = await create_workspace(client, owner, "知识通知")
|
||||
admin = await _member(client, owner, ws, "admin")
|
||||
|
||||
doc = unwrap(
|
||||
await client.post(
|
||||
f"{V1}/workspaces/{ws['id']}/knowledge",
|
||||
json={"title": "报价流程", "body_md": "先核价,再报给客户"},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
)
|
||||
|
||||
# 管理员新建草稿 → 其他管理员(owner)收到
|
||||
created = [
|
||||
n
|
||||
for n in await _inbox(client, owner, ws["id"])
|
||||
if n["kind"] == "knowledge_draft_created" and n["item_id"] == doc["id"]
|
||||
]
|
||||
assert len(created) == 1
|
||||
assert created[0]["item_kind"] == "knowledge"
|
||||
|
||||
# 提交核对 → owner 收到"待核对"
|
||||
doc = unwrap(
|
||||
await client.post(
|
||||
f"{V1}/workspaces/{ws['id']}/knowledge/{doc['id']}/status",
|
||||
json={"action": "submit_review", "version": doc["version"]},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
)
|
||||
assert any(
|
||||
n["kind"] == "knowledge_review_requested" and n["item_id"] == doc["id"]
|
||||
for n in await _inbox(client, owner, ws["id"])
|
||||
)
|
||||
|
||||
# 发布 → 作者(admin)收到
|
||||
doc = unwrap(
|
||||
await client.post(
|
||||
f"{V1}/workspaces/{ws['id']}/knowledge/{doc['id']}/status",
|
||||
json={"action": "publish", "version": doc["version"]},
|
||||
headers=owner["headers"],
|
||||
)
|
||||
)
|
||||
published = [
|
||||
n
|
||||
for n in await _inbox(client, admin, ws["id"])
|
||||
if n["kind"] == "knowledge_published" and n["item_id"] == doc["id"]
|
||||
]
|
||||
assert len(published) == 1
|
||||
assert published[0]["item_kind"] == "knowledge"
|
||||
|
||||
# 归档 → 作者(admin)收到
|
||||
unwrap(
|
||||
await client.post(
|
||||
f"{V1}/workspaces/{ws['id']}/knowledge/{doc['id']}/status",
|
||||
json={"action": "archive", "version": doc["version"]},
|
||||
headers=owner["headers"],
|
||||
)
|
||||
)
|
||||
assert any(
|
||||
n["kind"] == "knowledge_archived" and n["item_id"] == doc["id"]
|
||||
for n in await _inbox(client, admin, ws["id"])
|
||||
)
|
||||
|
||||
|
||||
async def _task_with_due(
|
||||
client: AsyncClient,
|
||||
owner: dict[str, Any],
|
||||
ws_id: str,
|
||||
assignee_id: str,
|
||||
title: str,
|
||||
due_at: datetime,
|
||||
status: str = "todo",
|
||||
) -> dict:
|
||||
return unwrap(
|
||||
await client.post(
|
||||
f"{V1}/workspaces/{ws_id}/items",
|
||||
json={
|
||||
"title": title,
|
||||
"status": status,
|
||||
"assignee_id": assignee_id,
|
||||
"due_at": due_at.isoformat(),
|
||||
},
|
||||
headers=owner["headers"],
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reminders_cover_due_soon_and_overdue_once(client: AsyncClient) -> None:
|
||||
"""扫描器只负责"该提醒的人收到且只收到一次",不负责业务判断。"""
|
||||
owner = await login(client)
|
||||
ws = await create_workspace(client, owner, "到期提醒")
|
||||
helper = await _member(client, owner, ws, "member")
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
soon = await _task_with_due(
|
||||
client, owner, ws["id"], helper["user_id"], "明早交付", now + timedelta(hours=3)
|
||||
)
|
||||
late = await _task_with_due(
|
||||
client, owner, ws["id"], helper["user_id"], "昨天该交", now - timedelta(days=1)
|
||||
)
|
||||
await _task_with_due(
|
||||
client,
|
||||
owner,
|
||||
ws["id"],
|
||||
helper["user_id"],
|
||||
"已经做完的",
|
||||
now + timedelta(hours=1),
|
||||
status="done",
|
||||
)
|
||||
|
||||
first = await reminders.tick()
|
||||
assert first["sent"] == 2, first
|
||||
|
||||
inbox = await _inbox(client, helper, ws["id"])
|
||||
due_soon = [n for n in inbox if n["kind"] == "task_due_soon"]
|
||||
overdue = [n for n in inbox if n["kind"] == "task_overdue"]
|
||||
assert [n["item_id"] for n in due_soon] == [soon["id"]]
|
||||
assert [n["item_id"] for n in overdue] == [late["id"]]
|
||||
assert "截止时间" in due_soon[0]["body"]
|
||||
assert "已逾期" in overdue[0]["title"]
|
||||
|
||||
# 第二轮不能重复写:dedupe_key 兜底
|
||||
second = await reminders.tick()
|
||||
assert second["sent"] == 0, second
|
||||
inbox = await _inbox(client, helper, ws["id"])
|
||||
assert len([n for n in inbox if n["kind"] == "task_due_soon"]) == 1
|
||||
assert len([n for n in inbox if n["kind"] == "task_overdue"]) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reminders_stay_inside_each_workspace(client: AsyncClient) -> None:
|
||||
"""提醒是平台级扫描,但写通知必须回到各自工作区,不能串租户。"""
|
||||
owner_a = await login(client)
|
||||
ws_a = await create_workspace(client, owner_a, "提醒甲")
|
||||
helper_a = await _member(client, owner_a, ws_a, "member")
|
||||
|
||||
owner_b = await login(client)
|
||||
ws_b = await create_workspace(client, owner_b, "提醒乙")
|
||||
helper_b = await _member(client, owner_b, ws_b, "member")
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
item_a = await _task_with_due(
|
||||
client, owner_a, ws_a["id"], helper_a["user_id"], "甲的逾期", now - timedelta(hours=2)
|
||||
)
|
||||
item_b = await _task_with_due(
|
||||
client, owner_b, ws_b["id"], helper_b["user_id"], "乙的逾期", now - timedelta(hours=2)
|
||||
)
|
||||
|
||||
result = await reminders.tick()
|
||||
assert result["sent"] == 2, result
|
||||
|
||||
inbox_a = await _inbox(client, helper_a, ws_a["id"])
|
||||
inbox_b = await _inbox(client, helper_b, ws_b["id"])
|
||||
assert {n["item_id"] for n in inbox_a if n["kind"] == "task_overdue"} == {item_a["id"]}
|
||||
assert {n["item_id"] for n in inbox_b if n["kind"] == "task_overdue"} == {item_b["id"]}
|
||||
|
||||
@@ -315,6 +315,64 @@ async def test_ticket_list_scopes(client: AsyncClient) -> None:
|
||||
assert empty["tickets"] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ticket_list_search(client: AsyncClient) -> None:
|
||||
"""手机端工单列表的搜索框:标题和正文都能命中,不相关的不出现。"""
|
||||
owner = await login(client)
|
||||
ws = await create_workspace(client, owner, "工单搜索")
|
||||
helper = await _member(client, owner, ws)
|
||||
task = await _task(client, owner, ws["id"], "支付回调重复")
|
||||
unwrap(
|
||||
await client.patch(
|
||||
f"{V1}/workspaces/{ws['id']}/items/{task['id']}",
|
||||
json={"body_md": "网关在 23:00 之后重复回调了三次", "version": task["version"]},
|
||||
headers=owner["headers"],
|
||||
)
|
||||
)
|
||||
unwrap(
|
||||
await client.post(
|
||||
f"{V1}/workspaces/{ws['id']}/items/{task['id']}/promote",
|
||||
json={"assignee_id": helper["user_id"]},
|
||||
headers=owner["headers"],
|
||||
)
|
||||
)
|
||||
other = await _task(client, owner, ws["id"], "登录页白屏")
|
||||
unwrap(
|
||||
await client.post(
|
||||
f"{V1}/workspaces/{ws['id']}/items/{other['id']}/promote",
|
||||
json={"assignee_id": helper["user_id"]},
|
||||
headers=owner["headers"],
|
||||
)
|
||||
)
|
||||
|
||||
by_title = unwrap(
|
||||
await client.get(
|
||||
f"{V1}/workspaces/{ws['id']}/tickets",
|
||||
params={"role": "all", "q": "支付"},
|
||||
headers=helper["headers"],
|
||||
)
|
||||
)
|
||||
assert [t["title"] for t in by_title["tickets"]] == ["支付回调重复"]
|
||||
|
||||
by_body = unwrap(
|
||||
await client.get(
|
||||
f"{V1}/workspaces/{ws['id']}/tickets",
|
||||
params={"role": "all", "q": "网关"},
|
||||
headers=helper["headers"],
|
||||
)
|
||||
)
|
||||
assert [t["title"] for t in by_body["tickets"]] == ["支付回调重复"]
|
||||
|
||||
miss = unwrap(
|
||||
await client.get(
|
||||
f"{V1}/workspaces/{ws['id']}/tickets",
|
||||
params={"role": "all", "q": "不存在的关键词"},
|
||||
headers=helper["headers"],
|
||||
)
|
||||
)
|
||||
assert miss["tickets"] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cross_tenant_ticket_invisible(client: AsyncClient) -> None:
|
||||
alice = await login(client)
|
||||
|
||||
@@ -106,6 +106,13 @@ export const Search = (p: IconProps) => (
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const Refresh = (p: IconProps) => (
|
||||
<svg {...base(p)}>
|
||||
<path d="M20 12a8 8 0 1 1-2.6-5.9" />
|
||||
<path d="M20 4v4h-4" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const Plus = (p: IconProps) => (
|
||||
<svg {...base(p)}>
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
|
||||
@@ -224,6 +224,8 @@ export interface AppNotification {
|
||||
id: string;
|
||||
kind: string;
|
||||
item_id: string | null;
|
||||
/** 事项类型:知识要跳阅读页,工单跳工单页,任务回任务页。 */
|
||||
item_kind: "task" | "ticket" | "knowledge" | null;
|
||||
item_title: string | null;
|
||||
title: string;
|
||||
body: string;
|
||||
|
||||
+297
-6
@@ -1,10 +1,24 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useAuth } from "../App";
|
||||
import { AppShell } from "../components/AppShell";
|
||||
import { AttachmentPanel } from "../components/AttachmentPanel";
|
||||
import { Markdown } from "../components/Markdown";
|
||||
import { RichTextEditor } from "../components/RichTextEditor";
|
||||
import { Check, Link2, More, Paperclip, Pencil, Plus, Search, Sparkle, Spinner, Trash } from "../components/icons";
|
||||
import {
|
||||
ArrowRight,
|
||||
Check,
|
||||
Link2,
|
||||
More,
|
||||
Paperclip,
|
||||
Pencil,
|
||||
Plus,
|
||||
Refresh,
|
||||
Search,
|
||||
Sparkle,
|
||||
Spinner,
|
||||
Trash,
|
||||
} from "../components/icons";
|
||||
import {
|
||||
Button,
|
||||
EmptyState,
|
||||
@@ -13,6 +27,7 @@ import {
|
||||
Sheet,
|
||||
SkeletonRows,
|
||||
StatusPill,
|
||||
useToast,
|
||||
} from "../components/ui";
|
||||
import { ApiError, api } from "../lib/client";
|
||||
import {
|
||||
@@ -81,6 +96,11 @@ export function KnowledgePage() {
|
||||
const [tags, setTags] = useState<KnowledgeTagCount[]>([]);
|
||||
const [tagFilter, setTagFilter] = useState<string | null>(null);
|
||||
const [catBusy, setCatBusy] = useState(false);
|
||||
/** 批量移动:先进入选择模式,再挑目标工作区/目录 */
|
||||
const [selecting, setSelecting] = useState(false);
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [moving, setMoving] = useState(false);
|
||||
const { push: toast } = useToast();
|
||||
const reqId = useRef(0);
|
||||
|
||||
const loadCategories = useCallback(async () => {
|
||||
@@ -243,6 +263,8 @@ export function KnowledgePage() {
|
||||
setHits(null);
|
||||
return;
|
||||
}
|
||||
// 检索结果是一组切片,和列表里的条目不是同一批东西,选中态在这里没有意义
|
||||
cancelSelect();
|
||||
setSearching(true);
|
||||
setError(null);
|
||||
try {
|
||||
@@ -286,6 +308,51 @@ export function KnowledgePage() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 刷新:列表、分类、标签一起重拉;检索态下重跑检索 */
|
||||
async function refresh() {
|
||||
if (hits !== null) await runSearch();
|
||||
else await load(null);
|
||||
await Promise.all([loadCategories(), loadTags()]);
|
||||
}
|
||||
|
||||
function startSelect() {
|
||||
setHits(null);
|
||||
setSelecting(true);
|
||||
setSelected(new Set());
|
||||
}
|
||||
|
||||
function cancelSelect() {
|
||||
setSelecting(false);
|
||||
setSelected(new Set());
|
||||
}
|
||||
|
||||
function toggleSelect(id: string) {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function toggleSelectAll() {
|
||||
setSelected((prev) =>
|
||||
prev.size === docs.length && docs.length > 0 ? new Set() : new Set(docs.map((d) => d.id)),
|
||||
);
|
||||
}
|
||||
|
||||
function onMoved(result: { moved: string[]; workspace_id: string; category_id: string | null }) {
|
||||
setMoving(false);
|
||||
cancelSelect();
|
||||
void load(null);
|
||||
void loadCategories();
|
||||
void loadTags();
|
||||
toast(
|
||||
`已移动 ${result.moved.length} 篇知识到${result.workspace_id === wsId ? "当前工作区" : "另一个工作区"}`,
|
||||
"success",
|
||||
);
|
||||
}
|
||||
|
||||
const pendingCount = docs.filter((d) => d.knowledge.status_hint !== "published").length;
|
||||
const activeCategory = categories.find((c) => c.id === catFilter) ?? null;
|
||||
|
||||
@@ -332,6 +399,7 @@ export function KnowledgePage() {
|
||||
selected={catFilter}
|
||||
loading={loading}
|
||||
onSelect={(value) => {
|
||||
cancelSelect();
|
||||
setCatFilter(value);
|
||||
setTagFilter(null);
|
||||
setHits(null);
|
||||
@@ -339,6 +407,7 @@ export function KnowledgePage() {
|
||||
tags={tags}
|
||||
tagSelected={tagFilter}
|
||||
onSelectTag={(name) => {
|
||||
cancelSelect();
|
||||
setTagFilter((prev) => (prev === name ? null : name));
|
||||
setHits(null);
|
||||
}}
|
||||
@@ -355,7 +424,11 @@ export function KnowledgePage() {
|
||||
return (
|
||||
<button
|
||||
key={t.key}
|
||||
onClick={() => setTab(t.key)}
|
||||
onClick={() => {
|
||||
// 切视图后列表换了,旧选中项可能已经不在眼前,别让人搬走看不见的东西
|
||||
cancelSelect();
|
||||
setTab(t.key);
|
||||
}}
|
||||
className={`rounded-lg px-2.5 py-1.5 text-sm font-medium transition-colors ${
|
||||
active
|
||||
? "bg-white text-ink-900 shadow-sm ring-1 ring-line-200"
|
||||
@@ -366,17 +439,64 @@ export function KnowledgePage() {
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<div className="ml-auto flex items-center gap-1.5">
|
||||
{loading ? (
|
||||
<span className="ml-auto inline-flex items-center gap-1.5 text-2xs text-ink-500">
|
||||
<span className="inline-flex items-center gap-1.5 text-2xs text-ink-500">
|
||||
<Spinner size={12} />
|
||||
载入中…
|
||||
</span>
|
||||
) : tab === "draft" && pendingCount > 0 ? (
|
||||
<span className="ml-auto hidden text-2xs text-ink-400 sm:block">
|
||||
<span className="hidden text-2xs text-ink-400 sm:block">
|
||||
{pendingCount} 份草稿等你核对
|
||||
</span>
|
||||
) : null}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={loading || searching}
|
||||
onClick={() => void refresh()}
|
||||
title="重新加载列表、分类和标签"
|
||||
>
|
||||
<Refresh size={13} />
|
||||
刷新
|
||||
</Button>
|
||||
<Button
|
||||
variant={selecting ? "subtle" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => (selecting ? cancelSelect() : startSelect())}
|
||||
title="选中多篇知识,一起移动到目录或其它工作区"
|
||||
>
|
||||
<Check size={13} />
|
||||
{selecting ? "退出批量" : "批量移动"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selecting && hits === null ? (
|
||||
<div className="mb-3 flex flex-wrap items-center gap-2 rounded-xl border border-primary-200 bg-primary-50/60 px-3 py-2">
|
||||
<span className="text-xs font-medium text-primary-800">
|
||||
已选 {selected.size} / {docs.length} 篇
|
||||
</span>
|
||||
<button className="link text-2xs" onClick={toggleSelectAll}>
|
||||
{selected.size === docs.length && docs.length > 0 ? "取消全选" : "全选列表"}
|
||||
</button>
|
||||
<span className="text-2xs text-ink-400">点卡片选中或取消</span>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={cancelSelect}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
disabled={selected.size === 0}
|
||||
onClick={() => setMoving(true)}
|
||||
>
|
||||
<ArrowRight size={13} />
|
||||
移动到…
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{error && (
|
||||
<div className="mb-4">
|
||||
@@ -483,9 +603,30 @@ export function KnowledgePage() {
|
||||
? { borderTopWidth: 3, borderTopColor: categoryColor(d.category) }
|
||||
: undefined
|
||||
}
|
||||
className="card group flex items-start justify-between gap-3 p-4 transition hover:border-line-300 hover:shadow-pop"
|
||||
className={`card group flex items-start justify-between gap-3 p-4 transition hover:border-line-300 hover:shadow-pop ${
|
||||
selecting && selected.has(d.id) ? "ring-2 ring-primary-300" : ""
|
||||
}`}
|
||||
>
|
||||
{selecting ? (
|
||||
<button
|
||||
type="button"
|
||||
role="checkbox"
|
||||
aria-checked={selected.has(d.id)}
|
||||
aria-label={`选择 ${d.title}`}
|
||||
onClick={() => toggleSelect(d.id)}
|
||||
className={`mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded border transition ${
|
||||
selected.has(d.id)
|
||||
? "border-primary-600 bg-primary-600 text-white"
|
||||
: "border-line-300 bg-white text-transparent hover:border-primary-400"
|
||||
}`}
|
||||
>
|
||||
<Check size={12} />
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
className="min-w-0 flex-1 text-left"
|
||||
onClick={() => (selecting ? toggleSelect(d.id) : openDoc(d.id))}
|
||||
>
|
||||
<button className="min-w-0 flex-1 text-left" onClick={() => openDoc(d.id)}>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="chip bg-primary-50 text-primary-700">
|
||||
{DOC_TYPE_LABEL[d.knowledge.doc_type] ?? d.knowledge.doc_type}
|
||||
@@ -535,6 +676,7 @@ export function KnowledgePage() {
|
||||
</div>
|
||||
) : null}
|
||||
</button>
|
||||
{selecting ? null : (
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Button variant="ghost" size="sm" onClick={() => openDoc(d.id)}>
|
||||
打开
|
||||
@@ -549,6 +691,7 @@ export function KnowledgePage() {
|
||||
<Trash size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -575,10 +718,158 @@ export function KnowledgePage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{moving && (
|
||||
<MoveKnowledgeSheet
|
||||
wsId={wsId}
|
||||
itemIds={[...selected]}
|
||||
onClose={() => setMoving(false)}
|
||||
onMoved={onMoved}
|
||||
/>
|
||||
)}
|
||||
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量移动:选目标工作区 + 目标目录。
|
||||
* 跨工作区时标签、评论、附件一起搬走;工单来源记成手动创建,免得目标库里挂一条看不见的工单。
|
||||
*/
|
||||
function MoveKnowledgeSheet({
|
||||
wsId,
|
||||
itemIds,
|
||||
onClose,
|
||||
onMoved,
|
||||
}: {
|
||||
wsId: string;
|
||||
itemIds: string[];
|
||||
onClose: () => void;
|
||||
onMoved: (result: { moved: string[]; workspace_id: string; category_id: string | null }) => void;
|
||||
}) {
|
||||
const { workspaces } = useAuth();
|
||||
const [targetWs, setTargetWs] = useState(wsId);
|
||||
const [cats, setCats] = useState<KnowledgeCategoryNode[]>([]);
|
||||
const [catId, setCatId] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
// 当前工作区排第一:多数时候只是整理目录,不该先让人跨区找自己
|
||||
const targets = useMemo(() => {
|
||||
const current = workspaces.find((w) => w.id === wsId);
|
||||
const rest = workspaces.filter((w) => w.id !== wsId && w.role !== "guest");
|
||||
return current ? [current, ...rest] : rest;
|
||||
}, [workspaces, wsId]);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
setLoading(true);
|
||||
setCatId("");
|
||||
api
|
||||
.get<{ items: KnowledgeCategoryNode[] }>(`/workspaces/${targetWs}/knowledge/categories`)
|
||||
.then((data) => {
|
||||
if (alive) setCats(data.items);
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (alive) setError(e instanceof ApiError ? e.message : "分类载入失败");
|
||||
})
|
||||
.finally(() => {
|
||||
if (alive) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [targetWs]);
|
||||
|
||||
async function submit() {
|
||||
if (busy || itemIds.length === 0) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const res = await api.post<{
|
||||
moved: string[];
|
||||
workspace_id: string;
|
||||
category_id: string | null;
|
||||
}>(`/workspaces/${wsId}/knowledge/bulk-move`, {
|
||||
item_ids: itemIds,
|
||||
target_workspace_id: targetWs === wsId ? null : targetWs,
|
||||
category_id: catId || null,
|
||||
});
|
||||
onMoved(res);
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof ApiError ? e.message : "移动失败,请重试");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet
|
||||
open
|
||||
onClose={onClose}
|
||||
size="md"
|
||||
title="移动知识"
|
||||
subtitle={`共 ${itemIds.length} 篇`}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={onClose} disabled={busy}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => void submit()}
|
||||
disabled={busy || loading || itemIds.length === 0}
|
||||
>
|
||||
{busy ? "移动中…" : "确认移动"}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{error ? <InlineAlert>{error}</InlineAlert> : null}
|
||||
<label className="block">
|
||||
<span className="label">目标工作区</span>
|
||||
<select
|
||||
className="input mt-1 w-full"
|
||||
value={targetWs}
|
||||
onChange={(e) => setTargetWs(e.target.value)}
|
||||
disabled={busy}
|
||||
>
|
||||
{targets.map((w) => (
|
||||
<option key={w.id} value={w.id}>
|
||||
{w.name}
|
||||
{w.id === wsId ? "(当前)" : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="label">目标目录</span>
|
||||
<select
|
||||
className="input mt-1 w-full"
|
||||
value={catId}
|
||||
onChange={(e) => setCatId(e.target.value)}
|
||||
disabled={busy || loading}
|
||||
>
|
||||
<option value="">未分类</option>
|
||||
{cats.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{"\u3000".repeat(Math.max(0, c.depth - 1))}
|
||||
{c.full_path || c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{targetWs !== wsId ? (
|
||||
<InlineAlert tone="info">
|
||||
会连同标签、评论、附件一起搬到目标工作区;原工单来源会记为手动创建,原工作区的通知与关联不再保留。
|
||||
</InlineAlert>
|
||||
) : null}
|
||||
</div>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
/** 分类色块:选中的那个带描边和钩,不用猜“现在到底是哪个色” */
|
||||
function ColorSwatch({
|
||||
value,
|
||||
|
||||
@@ -2,21 +2,33 @@ import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
|
||||
import { AppShell } from "../components/AppShell";
|
||||
import { Bell, Check, TicketIcon, Inbox, Clock } from "../components/icons";
|
||||
import { Bell, Book, Check, TicketIcon, Inbox, Clock, Flag } from "../components/icons";
|
||||
import { Button, EmptyState, PageHeader, SkeletonRows } from "../components/ui";
|
||||
import { api, ApiError } from "../lib/client";
|
||||
import type { AppNotification } from "../lib/types";
|
||||
|
||||
const PAGE = 30;
|
||||
|
||||
/** 工单类通知点开跳到工单页,其余回任务页——都带 `?open=`,落地即展开那一条。 */
|
||||
/**
|
||||
* 点开跳到哪:知识走独立阅读页(整屏),工单去工单页,其余回任务页——
|
||||
* 后两者都带 `?open=`,落地即展开那一条。判断以 `item_kind` 为准,
|
||||
* 因为"知识被评论"的通知 kind 是 item_commented,光看 kind 会跳错页。
|
||||
*/
|
||||
function targetOf(wsId: string, n: AppNotification): string {
|
||||
if (!n.item_id) return `/w/${wsId}/notifications`;
|
||||
const isTicket = n.kind.startsWith("ticket_");
|
||||
return isTicket ? `/w/${wsId}/tickets?open=${n.item_id}` : `/w/${wsId}?open=${n.item_id}`;
|
||||
if (n.item_kind === "knowledge" || n.kind.startsWith("knowledge_")) {
|
||||
return `/w/${wsId}/knowledge/${n.item_id}`;
|
||||
}
|
||||
if (n.item_kind === "ticket" || n.kind.startsWith("ticket_")) {
|
||||
return `/w/${wsId}/tickets?open=${n.item_id}`;
|
||||
}
|
||||
return `/w/${wsId}?open=${n.item_id}`;
|
||||
}
|
||||
|
||||
function iconOf(kind: string) {
|
||||
if (kind.startsWith("knowledge_")) return <Book size={15} />;
|
||||
if (kind === "task_due_soon") return <Clock size={15} />;
|
||||
if (kind === "task_overdue") return <Flag size={15} />;
|
||||
if (kind.startsWith("ticket_")) return <TicketIcon size={15} />;
|
||||
if (kind === "item_commented") return <Inbox size={15} />;
|
||||
return <Bell size={15} />;
|
||||
@@ -113,7 +125,7 @@ export function NotificationsPage() {
|
||||
<PageHeader
|
||||
eyebrow="事情有动静时,这里会告诉你"
|
||||
title="通知"
|
||||
desc="工单被接受、提交、验收,任务被指派、改状态,有人在你关注的事情下说话,都会落在这里。"
|
||||
desc="任务被指派、完成、快到期或已逾期,工单被接受/验收,知识待核对/已发布,以及有人在你关注的事情下说话,都会落在这里。"
|
||||
actions={
|
||||
<>
|
||||
<Button variant="ghost" size="sm" onClick={() => void load(true)} disabled={loading}>
|
||||
|
||||
Reference in New Issue
Block a user