diff --git a/packages/http-routes/src/routes/web/contacts.test.ts b/packages/http-routes/src/routes/web/contacts.test.ts index 63e2c7a7..61d75cc7 100644 --- a/packages/http-routes/src/routes/web/contacts.test.ts +++ b/packages/http-routes/src/routes/web/contacts.test.ts @@ -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() diff --git a/packages/http-routes/src/routes/web/contacts.ts b/packages/http-routes/src/routes/web/contacts.ts index 2a0b92f5..b4c37bba 100644 --- a/packages/http-routes/src/routes/web/contacts.ts +++ b/packages/http-routes/src/routes/web/contacts.ts @@ -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 { diff --git a/packages/node-runtime/src/services/contacts/compute.ts b/packages/node-runtime/src/services/contacts/compute.ts index 0e0261d7..3a4456fa 100644 --- a/packages/node-runtime/src/services/contacts/compute.ts +++ b/packages/node-runtime/src/services/contacts/compute.ts @@ -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 { diff --git a/packages/node-runtime/src/services/contacts/overrides.ts b/packages/node-runtime/src/services/contacts/overrides.ts new file mode 100644 index 00000000..d1f6c51e --- /dev/null +++ b/packages/node-runtime/src/services/contacts/overrides.ts @@ -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 +} + +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 = {} + + 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 { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} diff --git a/packages/node-runtime/src/services/contacts/service.test.ts b/packages/node-runtime/src/services/contacts/service.test.ts index dbc4331e..45f19b6d 100644 --- a/packages/node-runtime/src/services/contacts/service.test.ts +++ b/packages/node-runtime/src/services/contacts/service.test.ts @@ -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()) diff --git a/packages/node-runtime/src/services/contacts/service.ts b/packages/node-runtime/src/services/contacts/service.ts index 7f81884a..fd0419ad 100644 --- a/packages/node-runtime/src/services/contacts/service.ts +++ b/packages/node-runtime/src/services/contacts/service.ts @@ -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 @@ -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, diff --git a/packages/shared-types/index.ts b/packages/shared-types/index.ts index f31fff9b..6cbe63e8 100644 --- a/packages/shared-types/index.ts +++ b/packages/shared-types/index.ts @@ -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[] diff --git a/src/i18n/locales/en-US/contacts.json b/src/i18n/locales/en-US/contacts.json index b26c6849..fefe1602 100644 --- a/src/i18n/locales/en-US/contacts.json +++ b/src/i18n/locales/en-US/contacts.json @@ -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" } } diff --git a/src/i18n/locales/ja-JP/contacts.json b/src/i18n/locales/ja-JP/contacts.json index a03dff12..3eb2166b 100644 --- a/src/i18n/locales/ja-JP/contacts.json +++ b/src/i18n/locales/ja-JP/contacts.json @@ -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": "マーク解除に失敗しました" } } diff --git a/src/i18n/locales/zh-CN/contacts.json b/src/i18n/locales/zh-CN/contacts.json index b94e0029..9d0f5acc 100644 --- a/src/i18n/locales/zh-CN/contacts.json +++ b/src/i18n/locales/zh-CN/contacts.json @@ -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": "取消标记失败" } } diff --git a/src/i18n/locales/zh-TW/contacts.json b/src/i18n/locales/zh-TW/contacts.json index 30f07b64..f869587d 100644 --- a/src/i18n/locales/zh-TW/contacts.json +++ b/src/i18n/locales/zh-TW/contacts.json @@ -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": "取消標記失敗" } } diff --git a/src/pages/contacts/components/ContactDetailPanel.vue b/src/pages/contacts/components/ContactDetailPanel.vue index 5141fef8..eb9888ae 100644 --- a/src/pages/contacts/components/ContactDetailPanel.vue +++ b/src/pages/contacts/components/ContactDetailPanel.vue @@ -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" />
+
+ + {{ t('contacts.actions.markFriend') }} + + + {{ t('contacts.actions.unmarkFriend') }} + +
diff --git a/src/pages/contacts/index.vue b/src/pages/contacts/index.vue index 6e56c3d7..b3a16294 100644 --- a/src/pages/contacts/index.vue +++ b/src/pages/contacts/index.vue @@ -44,6 +44,7 @@ const activeContactSection = ref('friend') const selectedKey = ref(null) const selectedContact = ref(null) const isDetailLoading = ref(false) +const isFriendActionSaving = ref(false) const pollTimer = ref | null>(null) const searchTimer = ref | null>(null) const tabNavigationTimer = ref | 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" />
- {{ formatScore(contactRowAt(virtualRow.index).contact.score) }} + {{ formatContactScore(contactRowAt(virtualRow.index).contact) }} @@ -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" />
@@ -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 > * { diff --git a/src/services/data/fetch.ts b/src/services/data/fetch.ts index 769e00cc..19e53cd3 100644 --- a/src/services/data/fetch.ts +++ b/src/services/data/fetch.ts @@ -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 { + 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 { + 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 { diff --git a/src/services/data/types.ts b/src/services/data/types.ts index 1628a4ea..ca4cdf0b 100644 --- a/src/services/data/types.ts +++ b/src/services/data/types.ts @@ -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 getContactDetail(key: string, options?: ContactsFetchOptions): Promise recomputeContacts(options?: ContactsRecomputeOptions): Promise + markContactAsFriend(key: string, options?: ContactFriendMarkOptions): Promise + unmarkContactAsFriend(key: string, options?: ContactFriendMarkOptions): Promise // ==================== 时间范围 ====================