Compare commits

...
8 Commits
Author SHA1 Message Date
Steven 45d3222db6 fix(android): 顶部未读入口补图标并可点进通知页
CI / backend (push) Waiting to run
右上角之前只有一个未读数徽标,没有铃铛也不响应点击。改成常驻铃铛(未读时加深、红点计数),点击切到通知 tab,触控区 40dp。版本 0.5.1(versionCode 6)。
2026-11-10 16:54:46 +08:00
Steven 4cb054d32f feat(android): 列表刷新搜索、底栏图标、全屏阅读与内联图片
CI / backend (push) Waiting to run
任务/工单/知识列表补搜索与刷新(任务搜索改为停手 350ms 再请求);底栏五个入口配官方 Material 图标;知识正文改为整屏阅读页,不再用弹窗挤在 320dp 里;正文图片走 Markwon 渲染,相对地址补后端域名、附件链换 access token,超过 12MB 或加载失败不拖垮整篇正文。版本 0.5.0。
2026-11-10 16:34:01 +08:00
Steven d58cf7922e feat(ticket): 列表支持按标题/正文搜索
手机端工单列表要能像任务、知识一样搜。与知识列表同一套 ilike 匹配,标题记不清时正文关键词也能命中;补测试覆盖标题命中、正文命中、无结果三种情况。
2026-11-10 16:33:54 +08:00
Steven e127d5bd40 chore(android): 发版 0.4.0,带上站内通知
CI / backend (push) Waiting to run
2026-11-10 14:55:50 +08:00
Steven f54df70811 feat(notify): 站内通知补齐完成、到期逾期与知识事件
CI / backend (push) Waiting to run
2026-11-10 13:47:56 +08:00
Steven 4b7627d70e fix(knowledge): 切视图或检索时清掉批量选中,别搬走看不见的条目
CI / backend (push) Canceled after 0s
2026-11-07 01:57:22 +08:00
Steven 28eab4ae5f feat(knowledge): 知识页支持刷新列表与批量移动知识
CI / backend (push) Canceled after 0s
2026-11-07 01:50:37 +08:00
Steven dcd77c6b35 feat(knowledge): 批量把知识移到目录或其它工作区 2026-11-07 01:50:31 +08:00
26 changed files with 2387 additions and 337 deletions
+6 -2
View File
@@ -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
View File
@@ -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="状态已更新")
+364 -2
View File
@@ -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,
+22 -4
View File
@@ -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,
+8
View File
@@ -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 字是为了不把跨段的话切断(检索命中时前后文还读得懂)。
+4
View File
@@ -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()
+14
View File
@@ -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()
)
+52
View File
@@ -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]
+215
View File
@@ -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
+224
View File
@@ -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
+223
View File
@@ -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"]}
+58
View File
@@ -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)
+7
View File
@@ -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" />
+2
View File
@@ -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
View File
@@ -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,
+17 -5
View File
@@ -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}>