feat(contacts): mark groupmates as friends

This commit is contained in:
digua committed 2026-06-27 20:02:23 +08:00
1 parent 928ac6064b
commit ba500bd462
15 files changed
+477 -20

No files matched your search

@@ -72,6 +72,8 @@ class FakeContactsService implements ContactsService {
query?: string
}> = []
detailCalls: Array<{ key: string; acceptStale?: boolean; timeRangePreset?: string }> = []
markFriendCalls: Array<{ key: string; timeRangePreset?: string }> = []
unmarkFriendCalls: Array<{ key: string; timeRangePreset?: string }> = []
closeCalls = 0
getContacts(options?: { acceptStale?: boolean; timeRangePreset?: string }): ContactsResponse {
@@ -118,6 +120,16 @@ class FakeContactsService implements ContactsService {
return emptyContactsResponse('stale')
}
markContactAsFriend(key: string, options?: { timeRangePreset?: string }): { success: boolean } {
this.markFriendCalls.push({ key, timeRangePreset: options?.timeRangePreset })
return { success: true }
}
unmarkContactAsFriend(key: string, options?: { timeRangePreset?: string }): { success: boolean } {
this.unmarkFriendCalls.push({ key, timeRangePreset: options?.timeRangePreset })
return { success: true }
}
invalidateContactsCache(): void {
throw new Error('not used in route contract tests')
}
@@ -278,6 +290,31 @@ test('override routes are not registered', async (t) => {
assert.equal(deleted.statusCode, 404)
})
test('PUT and DELETE /_web/contacts/:key/mark-friend forward decoded key and time range', async (t) => {
const service = new FakeContactsService()
const app = Fastify()
t.after(async () => app.close())
registerContactsRoutes(app, createMockContext(service))
await app.ready()
const putResponse = await app.inject({
method: 'PUT',
url: `/_web/contacts/${encodeURIComponent('weixin:alice')}/mark-friend?timeRange=2y`,
})
assert.equal(putResponse.statusCode, 200)
assert.deepEqual(putResponse.json(), { success: true })
const deleteResponse = await app.inject({
method: 'DELETE',
url: `/_web/contacts/${encodeURIComponent('weixin:alice')}/mark-friend?timeRange=2y`,
})
assert.equal(deleteResponse.statusCode, 200)
assert.deepEqual(deleteResponse.json(), { success: true })
assert.deepEqual(service.markFriendCalls, [{ key: 'weixin:alice', timeRangePreset: '2y' }])
assert.deepEqual(service.unmarkFriendCalls, [{ key: 'weixin:alice', timeRangePreset: '2y' }])
})
test('closes contacts service when Fastify app closes', async () => {
const service = new FakeContactsService()
const app = Fastify()
@@ -52,6 +52,24 @@ export function registerContactsRoutes(server: FastifyInstance, ctx: HttpRouteCo
timeRangePreset: parseContactsTimeRangePreset(request.query.timeRange),
})
})
server.put<{ Params: { key: string }; Querystring: ContactsQuery }>(
'/_web/contacts/:key/mark-friend',
async (request) => {
return service.markContactAsFriend(request.params.key, {
timeRangePreset: parseContactsTimeRangePreset(request.query.timeRange),
})
}
)
server.delete<{ Params: { key: string }; Querystring: ContactsQuery }>(
'/_web/contacts/:key/mark-friend',
async (request) => {
return service.unmarkContactAsFriend(request.params.key, {
timeRangePreset: parseContactsTimeRangePreset(request.query.timeRange),
})
}
)
}
function isTruthy(value: string | undefined): boolean {
@@ -294,7 +294,7 @@ function toContactItem(
const aliases = [...acc.aliases].filter((alias) => alias !== acc.displayName)
const searchText = [acc.displayName, acc.platformId, ...aliases].join(' ').toLowerCase()
return {
const item: ContactItem = {
key: acc.key,
platform: acc.platform,
platformId: acc.platformId,
@@ -321,6 +321,8 @@ function toContactItem(
searchText,
lastInteractionTs: acc.lastInteractionTs,
}
if (pool === 'friend') item.friendSource = 'private'
return item
}
export function createEmptyContactsDiagnostics(): ContactsDiagnostics {
@@ -0,0 +1,74 @@
import fs from 'node:fs'
import path from 'node:path'
import { appLogger } from '../../logging/app-logger'
const CONTACT_OVERRIDES_FILE_NAME = 'contact-overrides.json'
const CONTACT_OVERRIDES_TMP_PREFIX = 'contact-overrides.tmp-'
const CONTACT_OVERRIDES_VERSION = 1
export interface ManualFriendOverride {
key: string
createdAt: number
updatedAt: number
}
export interface ContactOverridesFile {
version: 1
manualFriends: Record<string, ManualFriendOverride>
}
export interface ContactOverrideMutationResult {
success: boolean
}
export function createEmptyContactOverridesFile(): ContactOverridesFile {
return {
version: CONTACT_OVERRIDES_VERSION,
manualFriends: {},
}
}
export function getContactOverridesPath(snapshotDir: string): string {
return path.join(snapshotDir, CONTACT_OVERRIDES_FILE_NAME)
}
export function readContactOverrides(snapshotDir: string): ContactOverridesFile {
const filePath = getContactOverridesPath(snapshotDir)
if (!fs.existsSync(filePath)) return createEmptyContactOverridesFile()
try {
return normalizeContactOverrides(JSON.parse(fs.readFileSync(filePath, 'utf-8')))
} catch (error) {
appLogger.warn('contacts', 'contact overrides file is unreadable', error)
return createEmptyContactOverridesFile()
}
}
export function writeContactOverrides(snapshotDir: string, overrides: ContactOverridesFile): void {
if (!fs.existsSync(snapshotDir)) fs.mkdirSync(snapshotDir, { recursive: true })
const tmpPath = path.join(snapshotDir, `${CONTACT_OVERRIDES_TMP_PREFIX}${process.pid}-${Date.now()}`)
fs.writeFileSync(tmpPath, JSON.stringify(normalizeContactOverrides(overrides), null, 2), 'utf-8')
fs.renameSync(tmpPath, getContactOverridesPath(snapshotDir))
}
function normalizeContactOverrides(value: unknown): ContactOverridesFile {
const input = isRecord(value) ? value : {}
const manualFriendsInput = isRecord(input.manualFriends) ? input.manualFriends : {}
const manualFriends: Record<string, ManualFriendOverride> = {}
for (const [key, entry] of Object.entries(manualFriendsInput)) {
if (!key || !isRecord(entry)) continue
const createdAt = Number.isFinite(entry.createdAt) ? Number(entry.createdAt) : Date.now()
const updatedAt = Number.isFinite(entry.updatedAt) ? Number(entry.updatedAt) : createdAt
manualFriends[key] = { key, createdAt, updatedAt }
}
return {
version: CONTACT_OVERRIDES_VERSION,
manualFriends,
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
@@ -540,6 +540,78 @@ test('returns paginated lightweight contacts from a snapshot with full-snapshot
assert.equal('sourceSessions' in response.contacts[0], false)
})
test('applies manual friend overrides to paginated lists, stats, search, and detail without changing the snapshot', (t) => {
const env = new TestEnv()
t.after(() => env.cleanup())
const service = createContactsService({
adapter: env.adapter,
systemDir: env.dir,
runner: () => Promise.reject(new Error('not used')),
})
const snapshot = makeRuntimeSnapshot('sig-1', 1000)
snapshot.contacts = [
makeContact({ key: 'weixin:bob', displayName: 'Bob', pool: 'non_friend', score: 1, aliases: ['Builder'] }),
makeContact({ key: 'weixin:alice', displayName: 'Alice', pool: 'friend', score: 0.4 }),
makeContact({ key: 'weixin:carol', displayName: 'Carol', pool: 'non_friend', score: 0.3 }),
]
service.replaceSnapshotForTests!(snapshot)
assert.deepEqual((service as any).markContactAsFriend('weixin:bob', { acceptStale: true }), { success: true })
const friends = service.getContactsPage({ acceptStale: true, pool: 'friend', page: 1, pageSize: 10 })
assert.deepEqual(
friends.contacts.map((contact) => contact.key),
['weixin:alice', 'weixin:bob']
)
assert.deepEqual(friends.stats, { friendsTotal: 2, nonFriendsTotal: 1 })
assert.equal((friends.contacts[0] as any).friendSource, 'private')
assert.equal((friends.contacts[1] as any).friendSource, 'manual')
assert.equal(friends.contacts[1].pool, 'friend')
assert.equal(friends.contacts[1].isFriend, true)
const groupmates = service.getContactsPage({ acceptStale: true, pool: 'non_friend', page: 1, pageSize: 10 })
assert.deepEqual(
groupmates.contacts.map((contact) => contact.key),
['weixin:carol']
)
const search = service.getContactsPage({
acceptStale: true,
pool: 'friend',
page: 1,
pageSize: 10,
query: 'build',
})
assert.deepEqual(
search.contacts.map((contact) => contact.key),
['weixin:bob']
)
const detail = service.getContactDetail('weixin:bob', { acceptStale: true })
assert.equal(detail.contact?.pool, 'friend')
assert.equal(detail.contact?.isFriend, true)
assert.equal((detail.contact as any).friendSource, 'manual')
assert.equal(snapshot.contacts[0]?.pool, 'non_friend')
assert.deepEqual((service as any).unmarkContactAsFriend('weixin:bob'), { success: true })
const friendsAfterUnmark = service.getContactsPage({ acceptStale: true, pool: 'friend', page: 1, pageSize: 10 })
assert.deepEqual(
friendsAfterUnmark.contacts.map((contact) => contact.key),
['weixin:alice']
)
const groupmatesAfterUnmark = service.getContactsPage({
acceptStale: true,
pool: 'non_friend',
page: 1,
pageSize: 10,
})
assert.deepEqual(
groupmatesAfterUnmark.contacts.map((contact) => contact.key),
['weixin:bob', 'weixin:carol']
)
assert.equal((groupmatesAfterUnmark.contacts[0] as any).friendSource, undefined)
})
test('returns full contact detail from the selected time range snapshot', (t) => {
const env = new TestEnv()
t.after(() => env.cleanup())
@@ -20,6 +20,12 @@ import {
type ContactsComputeProgress,
type ContactsSnapshot,
} from './compute'
import {
readContactOverrides,
writeContactOverrides,
type ContactOverrideMutationResult,
type ContactOverridesFile,
} from './overrides'
import { buildContactsSignature } from './signature'
import { cleanupContactsSnapshotTempFiles, readContactsSnapshot, writeContactsSnapshot } from './snapshot'
import { normalizeContactsTimeRangePreset, resolveContactsTimeRange } from './time-range'
@@ -63,6 +69,8 @@ export interface ContactsService {
getContacts(options?: ContactsServiceOptions): ContactsResponse
getContactsPage(options?: ContactsServiceOptions): ContactsResponse
getContactDetail(key: string, options?: ContactsServiceOptions): ContactDetailResponse
markContactAsFriend(key: string, options?: ContactsServiceOptions): ContactOverrideMutationResult
unmarkContactAsFriend(key: string, options?: ContactsServiceOptions): ContactOverrideMutationResult
startRecompute(options?: ContactsServiceOptions): ContactsResponse
invalidateContactsCache(): void
close(): Promise<void>
@@ -120,7 +128,8 @@ class DefaultContactsService implements ContactsService {
const snapshot = this.getSnapshot(timeRangePreset)
const status = this.getCacheStatus(signature, timeRangePreset)
const includeSnapshot = status === 'fresh' || (status === 'stale' && options.acceptStale === true)
const contact = includeSnapshot ? (snapshot?.contacts.find((item) => item.key === key) ?? null) : null
const contacts = includeSnapshot ? this.getContactsWithOverrides(snapshot?.contacts ?? []) : []
const contact = contacts.find((item) => item.key === key) ?? null
return {
contact: contact ? sanitizeContactItem(contact) : null,
algorithmVersion: includeSnapshot
@@ -132,6 +141,33 @@ class DefaultContactsService implements ContactsService {
}
}
markContactAsFriend(key: string, options: ContactsServiceOptions = {}): ContactOverrideMutationResult {
const timeRangePreset = normalizeContactsTimeRangePreset(options.timeRangePreset)
const snapshot = this.getSnapshot(timeRangePreset)
const contact = snapshot?.contacts.find((item) => item.key === key)
if (!contact) throw createContactNotFoundError(key)
if (contact.pool === 'friend') return { success: true }
const now = this.now()
const overrides = this.readOverrides()
const existing = overrides.manualFriends[key]
overrides.manualFriends[key] = {
key,
createdAt: existing?.createdAt ?? now,
updatedAt: now,
}
this.writeOverrides(overrides, 'mark contact as friend')
return { success: true }
}
unmarkContactAsFriend(key: string): ContactOverrideMutationResult {
const overrides = this.readOverrides()
if (!overrides.manualFriends[key]) return { success: true }
delete overrides.manualFriends[key]
this.writeOverrides(overrides, 'unmark contact as friend')
return { success: true }
}
startRecompute(options: ContactsServiceOptions = {}): ContactsResponse {
const timeRangePreset = normalizeContactsTimeRangePreset(options.timeRangePreset)
const signature = buildContactsSignature(this.deps.adapter, timeRangePreset)
@@ -264,16 +300,18 @@ class DefaultContactsService implements ContactsService {
const snapshot = this.getSnapshot(timeRangePreset)
const status = this.getCacheStatus(signature, timeRangePreset)
const includeSnapshot = status === 'fresh' || (status === 'stale' && options.acceptStale === true)
const allContacts = includeSnapshot ? (snapshot?.contacts ?? []) : []
const allContacts = includeSnapshot ? this.getContactsWithOverrides(snapshot?.contacts ?? []) : []
const stats = buildContactsStats(allContacts)
const page = normalizePositiveInt(options.page, 1)
const pageSize = normalizePageSize(options.pageSize)
const query = options.query?.trim().toLowerCase() ?? ''
const filteredContacts = allContacts.filter((contact) => {
if (options.pool && contact.pool !== options.pool) return false
if (query && !contact.searchText.includes(query)) return false
return true
})
const filteredContacts = allContacts
.filter((contact) => {
if (options.pool && contact.pool !== options.pool) return false
if (query && !contact.searchText.includes(query)) return false
return true
})
.sort((a, b) => compareContactsForResponse(a, b, options.pool))
const offset = (page - 1) * pageSize
const pageContacts = filteredContacts.slice(offset, offset + pageSize).map(toContactListItem)
return {
@@ -319,6 +357,65 @@ class DefaultContactsService implements ContactsService {
private now(): number {
return this.deps.now?.() ?? Date.now()
}
private getContactsWithOverrides(contacts: ContactItem[]): ContactItem[] {
return applyContactOverrides(contacts, this.readOverrides())
}
private readOverrides(): ContactOverridesFile {
return readContactOverrides(this.snapshotDir)
}
private writeOverrides(overrides: ContactOverridesFile, action: string): void {
try {
writeContactOverrides(this.snapshotDir, overrides)
appLogger.info('contacts', `contact override saved: ${action}`, {
manualFriendCount: Object.keys(overrides.manualFriends).length,
})
} catch (error) {
appLogger.error('contacts', `failed to save contact override: ${action}`, error)
throw error
}
}
}
function applyContactOverrides(contacts: ContactItem[], overrides: ContactOverridesFile): ContactItem[] {
return contacts.map((contact) => {
if (contact.pool === 'friend') {
return {
...contact,
isFriend: true,
pool: 'friend',
friendSource: 'private',
}
}
if (overrides.manualFriends[contact.key]) {
return {
...contact,
isFriend: true,
pool: 'friend',
friendSource: 'manual',
}
}
const { friendSource: _friendSource, ...rest } = contact
return {
...rest,
isFriend: false,
pool: 'non_friend',
}
})
}
function compareContactsForResponse(a: ContactItem, b: ContactItem, pool: ContactPool | undefined): number {
if (pool === 'friend') {
const rankDiff = friendSortRank(a) - friendSortRank(b)
if (rankDiff !== 0) return rankDiff
}
return b.score - a.score || a.displayName.localeCompare(b.displayName)
}
function friendSortRank(contact: ContactItem): number {
return contact.friendSource === 'manual' ? 1 : 0
}
function buildContactsStats(contacts: ContactItem[]): { friendsTotal: number; nonFriendsTotal: number } {
@@ -355,9 +452,17 @@ function sanitizeContactItem(contact: ContactItem): ContactItem {
lastInteractionTs: contact.lastInteractionTs,
}
if (contact.sessionId) item.sessionId = contact.sessionId
if (contact.friendSource) item.friendSource = contact.friendSource
return item
}
function createContactNotFoundError(key: string): Error {
return Object.assign(new Error(`Contact not found: ${key}`), {
statusCode: 404,
code: 'CONTACT_NOT_FOUND',
})
}
function sanitizeContactsDiagnostics(diagnostics: ContactsDiagnostics): ContactsDiagnostics {
return {
privateSessionCount: diagnostics.privateSessionCount,
+3
View File
@@ -230,6 +230,8 @@ export interface SetOwnerAndApplyProfileResult {
export type ContactPool = 'friend' | 'non_friend'
export type ContactFriendSource = 'private' | 'manual'
export type ContactsCacheStatus = 'fresh' | 'stale' | 'missing'
export type ContactsTaskStatus = 'idle' | 'running' | 'succeeded' | 'failed' | 'superseded'
@@ -287,6 +289,7 @@ export interface ContactItem {
avatar: string | null
isFriend: boolean
pool: ContactPool
friendSource?: ContactFriendSource
score: number
scoreBreakdown: ContactScoreBreakdown
sourceSessions: ContactSourceSession[]
+8 -1
View File
@@ -29,6 +29,7 @@
},
"pool": {
"friend": "Friend",
"manualFriend": "Friend · Manual",
"nonFriend": "Groupmate"
},
"group": {
@@ -42,6 +43,8 @@
},
"actions": {
"recompute": "Recompute",
"markFriend": "Mark as friend",
"unmarkFriend": "Unmark friend",
"clearSearch": "Clear search",
"clearSelection": "Clear selection",
"viewDetail": "View details for {name}"
@@ -87,6 +90,10 @@
"toast": {
"recomputed": "Contacts recomputed",
"recomputeStarted": "Started computing contacts in the background",
"recomputeFailed": "Recompute failed"
"recomputeFailed": "Recompute failed",
"markFriendSucceeded": "Marked as friend",
"markFriendFailed": "Failed to mark friend",
"unmarkFriendSucceeded": "Friend mark removed",
"unmarkFriendFailed": "Failed to remove friend mark"
}
}
+8 -1
View File
@@ -29,6 +29,7 @@
},
"pool": {
"friend": "友人",
"manualFriend": "友人 · 手動",
"nonFriend": "グループ仲間"
},
"group": {
@@ -42,6 +43,8 @@
},
"actions": {
"recompute": "再計算",
"markFriend": "友人としてマーク",
"unmarkFriend": "マークを解除",
"clearSearch": "検索をクリア",
"clearSelection": "選択を解除",
"viewDetail": "{name} の詳細を表示"
@@ -87,6 +90,10 @@
"toast": {
"recomputed": "連絡先を再計算しました",
"recomputeStarted": "バックグラウンドで連絡先の計算を開始しました",
"recomputeFailed": "再計算に失敗しました"
"recomputeFailed": "再計算に失敗しました",
"markFriendSucceeded": "友人としてマークしました",
"markFriendFailed": "友人マークに失敗しました",
"unmarkFriendSucceeded": "マークを解除しました",
"unmarkFriendFailed": "マーク解除に失敗しました"
}
}
+8 -1
View File
@@ -29,6 +29,7 @@
},
"pool": {
"friend": "好友",
"manualFriend": "好友 · 手动标记",
"nonFriend": "群友"
},
"group": {
@@ -42,6 +43,8 @@
},
"actions": {
"recompute": "重新计算",
"markFriend": "标记为好友",
"unmarkFriend": "取消标记",
"clearSearch": "清空搜索",
"clearSelection": "取消选中",
"viewDetail": "查看 {name} 的详情"
@@ -87,6 +90,10 @@
"toast": {
"recomputed": "联系人已重新计算",
"recomputeStarted": "已开始后台计算联系人",
"recomputeFailed": "重新计算失败"
"recomputeFailed": "重新计算失败",
"markFriendSucceeded": "已标记为好友",
"markFriendFailed": "标记好友失败",
"unmarkFriendSucceeded": "已取消标记",
"unmarkFriendFailed": "取消标记失败"
}
}
+8 -1
View File
@@ -29,6 +29,7 @@
},
"pool": {
"friend": "好友",
"manualFriend": "好友 · 手動標記",
"nonFriend": "群友"
},
"group": {
@@ -42,6 +43,8 @@
},
"actions": {
"recompute": "重新計算",
"markFriend": "標記為好友",
"unmarkFriend": "取消標記",
"clearSearch": "清空搜尋",
"clearSelection": "取消選中",
"viewDetail": "查看 {name} 的詳情"
@@ -87,6 +90,10 @@
"toast": {
"recomputed": "聯絡人已重新計算",
"recomputeStarted": "已開始在背景計算聯絡人",
"recomputeFailed": "重新計算失敗"
"recomputeFailed": "重新計算失敗",
"markFriendSucceeded": "已標記為好友",
"markFriendFailed": "標記好友失敗",
"unmarkFriendSucceeded": "已取消標記",
"unmarkFriendFailed": "取消標記失敗"
}
}
@@ -7,11 +7,14 @@ const props = defineProps<{
selectedKey: string | null
contact: ContactItem | null
isLoading: boolean
isFriendActionLoading: boolean
}>()
const emit = defineEmits<{
clear: []
openSource: [source: ContactItem['sourceSessions'][number]]
markFriend: []
unmarkFriend: []
}>()
const { t } = useI18n()
@@ -23,6 +26,10 @@ const visibleAliases = computed(() => {
return contact.aliases.filter((alias) => !hidden.has(alias.trim().toLowerCase()))
})
const canMarkFriend = computed(() => props.contact?.pool === 'non_friend')
const canUnmarkFriend = computed(() => props.contact?.friendSource === 'manual')
const showFriendActions = computed(() => canMarkFriend.value || canUnmarkFriend.value)
function avatarText(contact: ContactItem): string {
return contact.displayName.trim().slice(0, 1).toUpperCase() || '?'
}
@@ -63,6 +70,8 @@ function avatarText(contact: ContactItem): string {
v-if="contact.avatar"
:src="contact.avatar"
:alt="contact.displayName"
loading="lazy"
decoding="async"
class="h-16 w-16 rounded-2xl object-cover shadow-sm ring-2 ring-white dark:ring-gray-900"
/>
<div
@@ -88,6 +97,30 @@ function avatarText(contact: ContactItem): string {
{{ alias }}
</span>
</div>
<div v-if="showFriendActions" class="mt-3 flex flex-wrap items-center gap-2">
<UButton
v-if="canMarkFriend"
icon="i-lucide-user-plus"
color="primary"
variant="soft"
size="xs"
:loading="isFriendActionLoading"
@click="emit('markFriend')"
>
{{ t('contacts.actions.markFriend') }}
</UButton>
<UButton
v-else-if="canUnmarkFriend"
icon="i-lucide-user-minus"
color="neutral"
variant="soft"
size="xs"
:loading="isFriendActionLoading"
@click="emit('unmarkFriend')"
>
{{ t('contacts.actions.unmarkFriend') }}
</UButton>
</div>
</div>
</div>
</section>
+66 -7
View File
@@ -44,6 +44,7 @@ const activeContactSection = ref<ContactPoolTab>('friend')
const selectedKey = ref<string | null>(null)
const selectedContact = ref<ContactItem | null>(null)
const isDetailLoading = ref(false)
const isFriendActionSaving = ref(false)
const pollTimer = ref<ReturnType<typeof setInterval> | null>(null)
const searchTimer = ref<ReturnType<typeof setTimeout> | null>(null)
const tabNavigationTimer = ref<ReturnType<typeof setTimeout> | null>(null)
@@ -452,9 +453,13 @@ function stopContactsPolling() {
async function selectContact(contact: ContactListItem) {
selectedKey.value = contact.key
const cacheKey = `${timeRangePreset.value}:${contact.key}`
await loadSelectedContactDetail(contact.key)
}
async function loadSelectedContactDetail(key: string, options?: { skipCache?: boolean }) {
const cacheKey = `${timeRangePreset.value}:${key}`
const cached = detailCache.value[cacheKey]
if (cached) {
if (cached && !options?.skipCache) {
selectedContact.value = cached
return
}
@@ -462,11 +467,11 @@ async function selectContact(contact: ContactListItem) {
selectedContact.value = null
isDetailLoading.value = true
try {
const detail = await dataService.getContactDetail(contact.key, {
const detail = await dataService.getContactDetail(key, {
acceptStale: true,
timeRangePreset: timeRangePreset.value,
})
if (selectedKey.value !== contact.key) return
if (selectedKey.value !== key) return
selectedContact.value = detail.contact
if (detail.contact) {
detailCache.value = {
@@ -477,10 +482,50 @@ async function selectContact(contact: ContactListItem) {
} catch (err) {
toast.fail(t('contacts.toast.recomputeFailed'), { description: String(err) })
} finally {
if (selectedKey.value === contact.key) isDetailLoading.value = false
if (selectedKey.value === key) isDetailLoading.value = false
}
}
async function markSelectedContactAsFriend() {
const key = selectedContact.value?.key
if (!key || isFriendActionSaving.value) return
isFriendActionSaving.value = true
try {
await dataService.markContactAsFriend(key, { timeRangePreset: timeRangePreset.value })
toast.success(t('contacts.toast.markFriendSucceeded'))
await refreshContactsAfterFriendAction(key)
} catch (err) {
toast.fail(t('contacts.toast.markFriendFailed'), { description: String(err) })
} finally {
isFriendActionSaving.value = false
}
}
async function unmarkSelectedContactAsFriend() {
const key = selectedContact.value?.key
if (!key || isFriendActionSaving.value) return
isFriendActionSaving.value = true
try {
await dataService.unmarkContactAsFriend(key, { timeRangePreset: timeRangePreset.value })
toast.success(t('contacts.toast.unmarkFriendSucceeded'))
await refreshContactsAfterFriendAction(key)
} catch (err) {
toast.fail(t('contacts.toast.unmarkFriendFailed'), { description: String(err) })
} finally {
isFriendActionSaving.value = false
}
}
async function refreshContactsAfterFriendAction(key: string) {
resetContactsState()
await Promise.all([
loadFirstPage('friend', { acceptStale: true }),
loadFirstPage('non_friend', { acceptStale: true }),
])
selectedKey.value = key
await loadSelectedContactDetail(key, { skipCache: true })
}
function clearSelectedContact() {
selectedKey.value = null
selectedContact.value = null
@@ -488,11 +533,15 @@ function clearSelectedContact() {
}
function contactBadgeLabel(contact: ContactListItem): string {
if (contact.friendSource === 'manual') return t('contacts.pool.manualFriend')
if (contact.pool === 'friend') return t('contacts.pool.friend')
return t('contacts.pool.nonFriend')
}
function contactBadgeClasses(contact: ContactListItem): string {
if (contact.friendSource === 'manual') {
return 'bg-indigo-50 text-indigo-600 dark:bg-indigo-500/10 dark:text-indigo-400 border border-indigo-100/50 dark:border-indigo-500/20'
}
if (contact.pool === 'friend') {
return 'bg-sky-50 text-sky-600 dark:bg-sky-500/10 dark:text-sky-400 border border-sky-100/50 dark:border-sky-500/20'
}
@@ -529,6 +578,11 @@ function formatScore(score: number): string {
return Math.round(score * 100).toString()
}
function formatContactScore(contact: ContactListItem): string {
if (contact.friendSource === 'manual') return '-'
return formatScore(contact.score)
}
function formatCount(value: number | undefined): string {
return String(value ?? 0)
}
@@ -813,6 +867,8 @@ function getGroupSectionStart(): number {
v-if="contactRowAt(virtualRow.index).contact.avatar"
:src="contactRowAt(virtualRow.index).contact.avatar ?? ''"
:alt="contactRowAt(virtualRow.index).contact.displayName"
loading="lazy"
decoding="async"
class="h-10 w-10 object-cover transition-transform duration-300 group-hover:scale-105"
/>
<div
@@ -886,7 +942,7 @@ function getGroupSectionStart(): number {
: 'bg-primary-50 text-primary-600 dark:bg-primary-500/10 dark:text-primary-400'
"
>
{{ formatScore(contactRowAt(virtualRow.index).contact.score) }}
{{ formatContactScore(contactRowAt(virtualRow.index).contact) }}
</span>
</button>
@@ -911,8 +967,11 @@ function getGroupSectionStart(): number {
:selected-key="selectedKey"
:contact="selectedContact"
:is-loading="isDetailLoading"
:is-friend-action-loading="isFriendActionSaving"
@clear="clearSelectedContact"
@open-source="openSourceSession"
@mark-friend="markSelectedContactAsFriend"
@unmark-friend="unmarkSelectedContactAsFriend"
/>
</div>
</div>
@@ -929,7 +988,7 @@ function getGroupSectionStart(): number {
.contact-table-grid--friends,
.contact-table-grid--group-contacts {
grid-template-columns: minmax(180px, 1.5fr) 88px 86px 86px 86px 104px 54px;
grid-template-columns: minmax(180px, 1.5fr) 118px 86px 86px 86px 104px 54px;
}
.contact-table-grid > * {
+21 -1
View File
@@ -50,7 +50,7 @@ import type {
MentionGraphData,
MessageLengthDistribution,
} from './types'
import { get, post, del, patch, analyticsGet } from '../utils/http'
import { get, post, del, put, patch, analyticsGet } from '../utils/http'
function buildFilterParams(filter?: TimeFilter): string {
if (!filter) return ''
@@ -138,6 +138,26 @@ export class FetchDataAdapter implements DataAdapter {
return post(`/contacts/recompute${qs ? `?${qs}` : ''}`, {})
}
async markContactAsFriend(key: string, options?: ContactsFetchOptions): Promise<boolean> {
const params = new URLSearchParams()
if (options?.timeRangePreset) params.set('timeRange', options.timeRangePreset)
const qs = params.toString()
const result = await put<{ success: boolean }>(
`/contacts/${encodeURIComponent(key)}/mark-friend${qs ? `?${qs}` : ''}`
)
return result.success
}
async unmarkContactAsFriend(key: string, options?: ContactsFetchOptions): Promise<boolean> {
const params = new URLSearchParams()
if (options?.timeRangePreset) params.set('timeRange', options.timeRangePreset)
const qs = params.toString()
const result = await del<{ success: boolean }>(
`/contacts/${encodeURIComponent(key)}/mark-friend${qs ? `?${qs}` : ''}`
)
return result.success
}
// ==================== 时间范围 ====================
getAvailableYears(sessionId: string): Promise<number[]> {
+6
View File
@@ -99,6 +99,10 @@ export interface ContactsRecomputeOptions {
query?: string
}
export interface ContactFriendMarkOptions {
timeRangePreset?: ContactsTimeRangePreset
}
// ==================== Mention Graph ====================
export interface MentionGraphData {
@@ -133,6 +137,8 @@ export interface DataAdapter {
getContacts(options?: ContactsFetchOptions): Promise<ContactsResponse>
getContactDetail(key: string, options?: ContactsFetchOptions): Promise<ContactDetailResponse>
recomputeContacts(options?: ContactsRecomputeOptions): Promise<ContactsResponse>
markContactAsFriend(key: string, options?: ContactFriendMarkOptions): Promise<boolean>
unmarkContactAsFriend(key: string, options?: ContactFriendMarkOptions): Promise<boolean>
// ==================== 时间范围 ====================