mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-26 02:04:37 +08:00
feat(contacts): mark groupmates as friends
This commit is contained in:
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,
|
||||
|
||||
@@ -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[]
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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": "マーク解除に失敗しました"
|
||||
}
|
||||
}
|
||||
@@ -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": "取消标记失败"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 > * {
|
||||
|
||||
@@ -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[]> {
|
||||
|
||||
@@ -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>
|
||||
|
||||
// ==================== 时间范围 ====================
|
||||
|
||||
|
||||
Reference in new issue
Block a user