feat: 私聊下新增主动性分析视图

This commit is contained in:
digua authored and digua committed 2026-04-10 00:47:56 +08:00
1 parent 0b89076e40
commit 11e530e5d4
22 files changed
+1628 -10

No files matched your search

+20
View File
@@ -598,6 +598,26 @@ export function registerChatHandlers(ctx: IpcContext): void {
}
)
/**
* 获取关系主动性分析数据(私聊专属)
*/
ipcMain.handle(
'chat:getRelationshipStats',
async (
_,
sessionId: string,
filter?: { startTs?: number; endTs?: number },
options?: { perseveranceThreshold?: number }
) => {
try {
return await worker.getRelationshipStats(sessionId, filter, options)
} catch (error) {
console.error('Failed to get relationship stats:', error)
return { months: [], members: [], totalSessions: 0, hasSessionIndex: false }
}
}
)
// ==================== 成员管理 ====================
/**
+2
View File
@@ -33,6 +33,7 @@ import {
getMentionGraph,
getLaughAnalysis,
getClusterGraph,
getRelationshipStats,
searchMessages,
deepSearchMessages,
getMessageContext,
@@ -174,6 +175,7 @@ const syncHandlers: Record<string, (payload: any) => any> = {
getMentionGraph: (p) => getMentionGraph(p.sessionId, p.filter),
getLaughAnalysis: (p) => getLaughAnalysis(p.sessionId, p.filter, p.keywords),
getClusterGraph: (p) => getClusterGraph(p.sessionId, p.filter, p.options),
getRelationshipStats: (p) => getRelationshipStats(p.sessionId, p.filter, p.options),
// AI 查询
searchMessages: (p) => searchMessages(p.sessionId, p.keywords, p.filter, p.limit, p.offset, p.senderId),
@@ -17,3 +17,13 @@ export type {
ClusterGraphLink,
ClusterGraphOptions,
} from './social'
// 关系分析(私聊主动性)
export { getRelationshipStats } from './relationship'
export type {
RelationshipStats,
RelationshipMonthStats,
IceBreakerItem,
ResponseLatencyMember,
PerseveranceMember,
} from './relationship'
@@ -0,0 +1,458 @@
/**
* 关系分析模块(私聊专属)
* 基于会话索引统计双方的主动发起、收尾、破冰、响应时延、锲而不舍行为
*/
import { openDatabase, type TimeFilter } from '../../core'
interface MemberMonthCount {
memberId: number
name: string
initiateCount: number
closeCount: number
}
export interface RelationshipMonthStats {
month: string
members: MemberMonthCount[]
totalSessions: number
}
export interface IceBreakerItem {
month: string
memberId: number
name: string
count: number
}
export interface ResponseLatencyMember {
memberId: number
name: string
avgResponseTime: number
totalResponses: number
}
export interface PerseveranceMember {
memberId: number
name: string
totalDoubleTexts: number
}
export interface MonthlyResponseLatency {
month: string
members: Array<{
memberId: number
name: string
avgResponseTime: number
responseCount: number
}>
}
export interface MonthlyPerseverance {
month: string
members: Array<{
memberId: number
name: string
doubleTextCount: number
}>
}
export interface RelationshipOptions {
perseveranceThreshold?: number
}
export interface RelationshipStats {
months: RelationshipMonthStats[]
members: Array<{
memberId: number
name: string
totalInitiateCount: number
totalCloseCount: number
}>
totalSessions: number
hasSessionIndex: boolean
iceBreakers: IceBreakerItem[]
totalIceBreaks: number
responseLatency: ResponseLatencyMember[]
perseverance: PerseveranceMember[]
totalDoubleTexts: number
monthlyResponseLatency: MonthlyResponseLatency[]
monthlyPerseverance: MonthlyPerseverance[]
perseveranceThreshold: number
}
const ICE_BREAK_THRESHOLD = 24 * 60 * 60
const DEFAULT_PERSEVERANCE_THRESHOLD = 300 // 5 minutes
export function getRelationshipStats(
sessionId: string,
filter?: TimeFilter,
options?: RelationshipOptions
): RelationshipStats {
const perseveranceThreshold = options?.perseveranceThreshold ?? DEFAULT_PERSEVERANCE_THRESHOLD
const db = openDatabase(sessionId)
const emptyResult: RelationshipStats = {
months: [],
members: [],
totalSessions: 0,
hasSessionIndex: false,
iceBreakers: [],
totalIceBreaks: 0,
responseLatency: [],
perseverance: [],
totalDoubleTexts: 0,
monthlyResponseLatency: [],
monthlyPerseverance: [],
perseveranceThreshold,
}
if (!db) return emptyResult
const sessionCount = db.prepare('SELECT COUNT(*) as count FROM chat_session').get() as { count: number } | undefined
if (!sessionCount || sessionCount.count === 0) {
return emptyResult
}
const timeConditions: string[] = []
const params: (number | string)[] = []
if (filter?.startTs !== undefined) {
timeConditions.push('cs.start_ts >= ?')
params.push(filter.startTs)
}
if (filter?.endTs !== undefined) {
timeConditions.push('cs.start_ts <= ?')
params.push(filter.endTs)
}
const whereClause = timeConditions.length > 0 ? `WHERE ${timeConditions.join(' AND ')}` : ''
// ==================== Session-level ====================
const sessionRows = db
.prepare(
`
SELECT
cs.id AS session_id,
cs.start_ts,
cs.end_ts,
(
SELECT m.sender_id
FROM message_context mc
JOIN message m ON m.id = mc.message_id
WHERE mc.session_id = cs.id
ORDER BY m.ts ASC, m.id ASC
LIMIT 1
) AS initiator_id,
(
SELECT m.sender_id
FROM message_context mc
JOIN message m ON m.id = mc.message_id
WHERE mc.session_id = cs.id
ORDER BY m.ts DESC, m.id DESC
LIMIT 1
) AS closer_id
FROM chat_session cs
${whereClause}
ORDER BY cs.start_ts ASC
`
)
.all(...params) as Array<{
session_id: number
start_ts: number
end_ts: number
initiator_id: number | null
closer_id: number | null
}>
const memberNames = new Map<number, string>()
const memberRows = db
.prepare('SELECT id, COALESCE(group_nickname, account_name, platform_id) as name FROM member')
.all() as Array<{ id: number; name: string }>
for (const row of memberRows) {
memberNames.set(row.id, row.name)
}
const monthMap = new Map<
string,
{ initiateMap: Map<number, number>; closeMap: Map<number, number>; totalSessions: number }
>()
const memberInitTotals = new Map<number, number>()
const memberCloseTotals = new Map<number, number>()
const iceBreakMap = new Map<string, Map<number, number>>()
let totalIceBreaks = 0
let prevEndTs: number | null = null
for (const row of sessionRows) {
const month = toLocalMonth(row.start_ts)
if (!monthMap.has(month)) {
monthMap.set(month, { initiateMap: new Map(), closeMap: new Map(), totalSessions: 0 })
}
const ms = monthMap.get(month)!
ms.totalSessions++
if (row.initiator_id !== null) {
ms.initiateMap.set(row.initiator_id, (ms.initiateMap.get(row.initiator_id) ?? 0) + 1)
memberInitTotals.set(row.initiator_id, (memberInitTotals.get(row.initiator_id) ?? 0) + 1)
}
if (row.closer_id !== null) {
ms.closeMap.set(row.closer_id, (ms.closeMap.get(row.closer_id) ?? 0) + 1)
memberCloseTotals.set(row.closer_id, (memberCloseTotals.get(row.closer_id) ?? 0) + 1)
}
if (prevEndTs !== null && row.initiator_id !== null) {
if (row.start_ts - prevEndTs > ICE_BREAK_THRESHOLD) {
if (!iceBreakMap.has(month)) iceBreakMap.set(month, new Map())
const mMap = iceBreakMap.get(month)!
mMap.set(row.initiator_id, (mMap.get(row.initiator_id) ?? 0) + 1)
totalIceBreaks++
}
}
prevEndTs = row.end_ts
}
const allMemberIds = new Set<number>()
for (const id of memberInitTotals.keys()) allMemberIds.add(id)
for (const id of memberCloseTotals.keys()) allMemberIds.add(id)
const monthKeys = Array.from(monthMap.keys()).sort((a, b) => b.localeCompare(a))
const months: RelationshipMonthStats[] = monthKeys.map((month) => {
const ms = monthMap.get(month)!
const members: MemberMonthCount[] = Array.from(allMemberIds).map((memberId) => ({
memberId,
name: memberNames.get(memberId) ?? `Unknown(${memberId})`,
initiateCount: ms.initiateMap.get(memberId) ?? 0,
closeCount: ms.closeMap.get(memberId) ?? 0,
}))
return { month, members, totalSessions: ms.totalSessions }
})
const totalSessions = sessionRows.length
const members = Array.from(allMemberIds)
.map((memberId) => ({
memberId,
name: memberNames.get(memberId) ?? `Unknown(${memberId})`,
totalInitiateCount: memberInitTotals.get(memberId) ?? 0,
totalCloseCount: memberCloseTotals.get(memberId) ?? 0,
}))
.sort((a, b) => b.totalInitiateCount - a.totalInitiateCount)
const iceBreakers: IceBreakerItem[] = []
for (const month of monthKeys) {
const mMap = iceBreakMap.get(month)
if (!mMap) continue
for (const [memberId, count] of mMap) {
iceBreakers.push({ month, memberId, name: memberNames.get(memberId) ?? `Unknown(${memberId})`, count })
}
}
// ==================== Message-level ====================
const sessionIdList = sessionRows.map((r) => r.session_id)
const msgStats = queryMessageLevelStats(db, sessionIdList, memberNames, perseveranceThreshold)
return {
months,
members,
totalSessions,
hasSessionIndex: true,
iceBreakers,
totalIceBreaks,
...msgStats,
perseveranceThreshold,
}
}
function queryMessageLevelStats(
db: ReturnType<typeof openDatabase>,
sessionIds: number[],
memberNames: Map<number, string>,
perseveranceThreshold: number
): {
responseLatency: ResponseLatencyMember[]
perseverance: PerseveranceMember[]
totalDoubleTexts: number
monthlyResponseLatency: MonthlyResponseLatency[]
monthlyPerseverance: MonthlyPerseverance[]
} {
const empty = {
responseLatency: [],
perseverance: [],
totalDoubleTexts: 0,
monthlyResponseLatency: [],
monthlyPerseverance: [],
}
if (!db || sessionIds.length === 0) return empty
const BATCH_SIZE = 500
// Overall
const responseTotals = new Map<number, { sum: number; count: number }>()
const dtTotals = new Map<number, number>()
// Monthly
const monthlyRespMap = new Map<string, Map<number, { sum: number; count: number }>>()
const monthlyDtMap = new Map<string, Map<number, number>>()
for (let i = 0; i < sessionIds.length; i += BATCH_SIZE) {
const batch = sessionIds.slice(i, i + BATCH_SIZE)
const placeholders = batch.map(() => '?').join(',')
// 响应时延(overall + monthly)
const latencyRows = db
.prepare(
`
WITH msg_lag AS (
SELECT
m.sender_id,
m.ts,
LAG(m.sender_id) OVER (PARTITION BY mc.session_id ORDER BY m.ts, m.id) AS prev_sender_id,
LAG(m.ts) OVER (PARTITION BY mc.session_id ORDER BY m.ts, m.id) AS prev_ts
FROM message_context mc
JOIN message m ON m.id = mc.message_id
WHERE mc.session_id IN (${placeholders})
AND m.type = 0
)
SELECT
strftime('%Y-%m', datetime(ts, 'unixepoch', 'localtime')) AS month,
sender_id AS responder_id,
SUM(ts - prev_ts) AS total_time,
COUNT(*) AS response_count
FROM msg_lag
WHERE prev_sender_id IS NOT NULL AND sender_id != prev_sender_id
GROUP BY month, responder_id
`
)
.all(...batch) as Array<{
month: string
responder_id: number
total_time: number
response_count: number
}>
for (const row of latencyRows) {
// overall
const existing = responseTotals.get(row.responder_id)
if (existing) {
existing.sum += row.total_time
existing.count += row.response_count
} else {
responseTotals.set(row.responder_id, { sum: row.total_time, count: row.response_count })
}
// monthly
if (!monthlyRespMap.has(row.month)) monthlyRespMap.set(row.month, new Map())
const mMap = monthlyRespMap.get(row.month)!
const mExisting = mMap.get(row.responder_id)
if (mExisting) {
mExisting.sum += row.total_time
mExisting.count += row.response_count
} else {
mMap.set(row.responder_id, { sum: row.total_time, count: row.response_count })
}
}
// 锲而不舍(overall + monthly),带时间阈值
const dtRows = db
.prepare(
`
WITH msg_lag AS (
SELECT
m.sender_id,
m.ts,
LAG(m.sender_id) OVER (PARTITION BY mc.session_id ORDER BY m.ts, m.id) AS prev_sender_id,
LAG(m.ts) OVER (PARTITION BY mc.session_id ORDER BY m.ts, m.id) AS prev_ts
FROM message_context mc
JOIN message m ON m.id = mc.message_id
WHERE mc.session_id IN (${placeholders})
AND m.type = 0
)
SELECT
strftime('%Y-%m', datetime(ts, 'unixepoch', 'localtime')) AS month,
sender_id,
COUNT(*) AS double_text_count
FROM msg_lag
WHERE prev_sender_id IS NOT NULL
AND sender_id = prev_sender_id
AND (ts - prev_ts) >= ?
GROUP BY month, sender_id
`
)
.all(...batch, perseveranceThreshold) as Array<{
month: string
sender_id: number
double_text_count: number
}>
for (const row of dtRows) {
// overall
dtTotals.set(row.sender_id, (dtTotals.get(row.sender_id) ?? 0) + row.double_text_count)
// monthly
if (!monthlyDtMap.has(row.month)) monthlyDtMap.set(row.month, new Map())
const mMap = monthlyDtMap.get(row.month)!
mMap.set(row.sender_id, (mMap.get(row.sender_id) ?? 0) + row.double_text_count)
}
}
// Build overall results
const responseLatency: ResponseLatencyMember[] = Array.from(responseTotals.entries())
.map(([memberId, { sum, count }]) => ({
memberId,
name: memberNames.get(memberId) ?? `Unknown(${memberId})`,
avgResponseTime: Math.round(sum / count),
totalResponses: count,
}))
.sort((a, b) => a.avgResponseTime - b.avgResponseTime)
let totalDoubleTexts = 0
const perseverance: PerseveranceMember[] = Array.from(dtTotals.entries())
.map(([memberId, count]) => {
totalDoubleTexts += count
return {
memberId,
name: memberNames.get(memberId) ?? `Unknown(${memberId})`,
totalDoubleTexts: count,
}
})
.sort((a, b) => b.totalDoubleTexts - a.totalDoubleTexts)
// Build monthly response latency
const monthlyResponseLatency: MonthlyResponseLatency[] = Array.from(monthlyRespMap.entries())
.sort(([a], [b]) => b.localeCompare(a))
.map(([month, mMap]) => ({
month,
members: Array.from(mMap.entries())
.map(([memberId, { sum, count }]) => ({
memberId,
name: memberNames.get(memberId) ?? `Unknown(${memberId})`,
avgResponseTime: Math.round(sum / count),
responseCount: count,
}))
.sort((a, b) => a.avgResponseTime - b.avgResponseTime),
}))
// Build monthly perseverance
const monthlyPerseverance: MonthlyPerseverance[] = Array.from(monthlyDtMap.entries())
.sort(([a], [b]) => b.localeCompare(a))
.map(([month, mMap]) => ({
month,
members: Array.from(mMap.entries())
.map(([memberId, count]) => ({
memberId,
name: memberNames.get(memberId) ?? `Unknown(${memberId})`,
doubleTextCount: count,
}))
.sort((a, b) => b.doubleTextCount - a.doubleTextCount),
}))
return { responseLatency, perseverance, totalDoubleTexts, monthlyResponseLatency, monthlyPerseverance }
}
function toLocalMonth(ts: number): string {
const d = new Date(ts * 1000)
const year = d.getFullYear()
const month = String(d.getMonth() + 1).padStart(2, '0')
return `${year}-${month}`
}
+10
View File
@@ -36,11 +36,21 @@ export {
getMentionGraph,
getLaughAnalysis,
getClusterGraph,
getRelationshipStats,
} from './advanced'
// 小团体图类型
export type { ClusterGraphData, ClusterGraphNode, ClusterGraphLink, ClusterGraphOptions } from './advanced'
// 关系分析类型
export type {
RelationshipStats,
RelationshipMonthStats,
IceBreakerItem,
ResponseLatencyMember,
PerseveranceMember,
} from './advanced'
// 聊天记录查询
export {
searchMessages,
+4
View File
@@ -329,6 +329,10 @@ export async function getClusterGraph(sessionId: string, filter?: any, options?:
return sendToWorker('getClusterGraph', { sessionId, filter, options })
}
export async function getRelationshipStats(sessionId: string, filter?: any, options?: any): Promise<any> {
return sendToWorker('getRelationshipStats', { sessionId, filter, options })
}
export async function getAllSessions(): Promise<any[]> {
return sendToWorker('getAllSessions', {})
}
+12
View File
@@ -16,6 +16,7 @@ import type {
MemberWithStats,
ClusterGraphData,
ClusterGraphOptions,
RelationshipStats,
} from '../../../src/types/analysis'
import type { FileParseInfo, ConflictCheckResult, MergeParams, MergeResult } from '../../../src/types/format'
@@ -295,6 +296,17 @@ export const chatApi = {
return ipcRenderer.invoke('chat:getLaughAnalysis', sessionId, filter, keywords)
},
/**
* 获取关系主动性分析数据(私聊专属)
*/
getRelationshipStats: (
sessionId: string,
filter?: { startTs?: number; endTs?: number },
options?: { perseveranceThreshold?: number }
): Promise<RelationshipStats> => {
return ipcRenderer.invoke('chat:getRelationshipStats', sessionId, filter, options)
},
// ==================== 成员管理 ====================
/**
+6
View File
@@ -14,6 +14,7 @@ import type {
MemberWithStats,
ClusterGraphData,
ClusterGraphOptions,
RelationshipStats,
} from '../../src/types/analysis'
import type { FileParseInfo, ConflictCheckResult, MergeParams, MergeResult } from '../../src/types/format'
import type { TableSchema, SQLResult } from '../../src/components/analysis/SQLLab/types'
@@ -141,6 +142,11 @@ interface ChatApi {
getMentionGraph: (sessionId: string, filter?: TimeFilter) => Promise<MentionGraphData>
getClusterGraph: (sessionId: string, filter?: TimeFilter, options?: ClusterGraphOptions) => Promise<ClusterGraphData>
getLaughAnalysis: (sessionId: string, filter?: TimeFilter, keywords?: string[]) => Promise<LaughAnalysis>
getRelationshipStats: (
sessionId: string,
filter?: TimeFilter,
options?: { perseveranceThreshold?: number }
) => Promise<RelationshipStats>
// 成员管理
getMembers: (sessionId: string) => Promise<MemberWithStats[]>
getMembersPaginated: (
+2 -1
View File
@@ -54,7 +54,8 @@
"view": {
"message": "Messages",
"interaction": "Interactions",
"ranking": "Rankings"
"ranking": "Rankings",
"relationship": "Relationship"
},
"quotes": {
"wordcloud": "Word Cloud",
+66
View File
@@ -55,6 +55,72 @@
"graphHint": "{nodes} members, {links} interactions",
"noInteraction": "No mention interaction data"
},
"relationship": {
"loading": "Analyzing relationship dynamics…",
"noIndex": {
"title": "Session Index Required",
"description": "Relationship analysis requires session index data. Please generate the session index from the overview page first."
},
"empty": {
"title": "No Relationship Data",
"description": "No sessions found in the selected time range."
},
"overview": {
"title": "Overall Summary"
},
"monthly": {
"title": "Monthly Breakdown"
},
"trend": {
"title": "Initiative Trend",
"hint": "Blue line shows {name}'s monthly initiative ratio; 50% line is the balanced baseline"
},
"initiator": "Start Conversations",
"closer": "End Conversations",
"initiated": "Initiated {count} times",
"closed": "Closed {count} times",
"totalSessions": "{count} total sessions",
"times": " times",
"noActivity": "No activity this month",
"monthFormat": "{month}/{year}",
"monthDetail": "Init {init} / Close {close}",
"iceBreaker": {
"title": "Ice Breaker",
"hint": "Times breaking silence after 24+ hours of inactivity",
"count": "{count} ice breaks",
"total": "{count} total ice breaks"
},
"responseLatency": {
"title": "Response Time",
"hint": "Average time to reply after the other person sends a message",
"count": "{count} replies",
"seconds": "{n}s",
"minutes": "{n} min",
"hours": "{n}h",
"hoursMinutes": "{h}h {m}min"
},
"perseverance": {
"title": "Perseverance",
"hint": "Times sending another message before the other person replied",
"hintWithThreshold": "Times sending another message when the other person hasn't replied within {threshold}",
"thresholdMinutes": "{n} min",
"count": "{count} times",
"total": "{count} total",
"threshold": "Gap",
"empty": "No perseverance records with this threshold"
},
"labels": {
"mutualPursuit": "Mutual Pursuit",
"silentGuardian": "Silent Guardian",
"devotedHeart": "Devoted Heart",
"distantBond": "Distant Bond",
"wellMatched": "Well Matched",
"missingYou": "{name} Misses You",
"monologue": "{name}'s Monologue",
"peacefulSilence": "Peaceful Silence",
"fleetingThoughts": "Fleeting Thoughts"
}
},
"timeline": {
"title": "Timeline View",
"description": "Message time distribution, active period trends and other visualizations"
+2 -1
View File
@@ -54,7 +54,8 @@
"view": {
"message": "メッセージ",
"interaction": "インタラクション分析",
"ranking": "ランキング"
"ranking": "ランキング",
"relationship": "関係"
},
"quotes": {
"wordcloud": "ワードクラウド",
+66
View File
@@ -55,6 +55,72 @@
"graphHint": "全 {nodes} 名のメンバー、{links} 件のインタラクション",
"noInteraction": "メンションデータがありません"
},
"relationship": {
"loading": "関係動態を分析中…",
"noIndex": {
"title": "セッションインデックスが必要です",
"description": "関係分析にはセッションインデックスが必要です。概要ページからセッションインデックスを生成してください。"
},
"empty": {
"title": "関係データがありません",
"description": "選択した期間内にセッションが見つかりません。"
},
"overview": {
"title": "全体概要"
},
"monthly": {
"title": "月別明細"
},
"trend": {
"title": "主動性の推移",
"hint": "青い線は {name} の月別主動発起率。50% 線がバランス基準です"
},
"initiator": "話題を始める",
"closer": "話題を終える",
"initiated": "{count} 回発起",
"closed": "{count} 回終了",
"totalSessions": "合計 {count} セッション",
"times": "回",
"noActivity": "今月はやり取りなし",
"monthFormat": "{year}年{month}月",
"monthDetail": "発起 {init} / 終了 {close}",
"iceBreaker": {
"title": "アイスブレイカー",
"hint": "24時間以上の沈黙を破って話しかけた回数",
"count": "{count} 回の破氷",
"total": "合計 {count} 回の破氷"
},
"responseLatency": {
"title": "返信時間",
"hint": "相手がメッセージを送った後、平均何分で返信するか",
"count": "{count} 回の返信",
"seconds": "{n} 秒",
"minutes": "{n} 分",
"hours": "{n} 時間",
"hoursMinutes": "{h} 時間 {m} 分"
},
"perseverance": {
"title": "粘り強さ",
"hint": "相手が返信する前にもう一通メッセージを送った回数",
"hintWithThreshold": "相手が{threshold}以内に返信しないうちに、もう一通メッセージを送った回数",
"thresholdMinutes": "{n}分",
"count": "{count} 回",
"total": "合計 {count} 回",
"threshold": "間隔",
"empty": "この閾値では粘り強さの記録がありません"
},
"labels": {
"mutualPursuit": "双方向の想い",
"silentGuardian": "静かな守り手",
"devotedHeart": "一途な想い",
"distantBond": "遠い絆",
"wellMatched": "互角",
"missingYou": "{name} が恋しがっている",
"monologue": "{name} の独白",
"peacefulSilence": "穏やかな沈黙",
"fleetingThoughts": "ふとした思い出"
}
},
"timeline": {
"title": "タイムライン表示",
"description": "メッセージの時間分布や活動周期の変化を可視化します"
+2 -1
View File
@@ -54,7 +54,8 @@
"view": {
"message": "消息",
"interaction": "互动分析",
"ranking": "榜单"
"ranking": "榜单",
"relationship": "关系"
},
"quotes": {
"wordcloud": "词云",
+66
View File
@@ -55,6 +55,72 @@
"graphHint": "共 {nodes} 位成员,{links} 条互动关系",
"noInteraction": "暂无艾特互动数据"
},
"relationship": {
"loading": "正在分析关系动态…",
"noIndex": {
"title": "需要会话索引",
"description": "关系分析依赖会话索引数据,请先在总览页生成会话索引。"
},
"empty": {
"title": "暂无关系数据",
"description": "当前时间范围内没有会话记录。"
},
"overview": {
"title": "整体概览"
},
"monthly": {
"title": "月度明细"
},
"trend": {
"title": "主动性趋势",
"hint": "蓝色线为 {name} 的月度主动发起占比,50% 线为均衡基准"
},
"initiator": "发起话题",
"closer": "结束话题",
"initiated": "发起 {count} 次",
"closed": "结束 {count} 次",
"totalSessions": "共 {count} 次会话",
"times": "次",
"noActivity": "本月无互动",
"monthFormat": "{year}年{month}月",
"monthDetail": "发起 {init} / 结束 {close}",
"iceBreaker": {
"title": "破冰达人",
"hint": "超过 24 小时沉默后主动打破僵局的次数",
"count": "{count} 次破冰",
"total": "共 {count} 次破冰"
},
"responseLatency": {
"title": "响应时延",
"hint": "当对方发来消息后,平均多久回复",
"count": "{count} 次回复",
"seconds": "{n} 秒",
"minutes": "{n} 分钟",
"hours": "{n} 小时",
"hoursMinutes": "{h} 小时 {m} 分钟"
},
"perseverance": {
"title": "锲而不舍",
"hint": "对方还没回复就又发了一条消息的次数",
"hintWithThreshold": "对方{threshold}内还没回复就又发了一次消息的次数",
"thresholdMinutes": "{n}分钟",
"count": "{count} 次",
"total": "共 {count} 次",
"threshold": "间隔",
"empty": "在当前阈值下没有锲而不舍的记录"
},
"labels": {
"mutualPursuit": "双向奔赴",
"silentGuardian": "默默守候",
"devotedHeart": "一往情深",
"distantBond": "若即若离",
"wellMatched": "势均力敌",
"missingYou": "{name} 在想念",
"monologue": "{name} 的独白",
"peacefulSilence": "各自安好",
"fleetingThoughts": "偶尔想起"
}
},
"timeline": {
"title": "时间线视图",
"description": "消息时间分布、活跃周期变化趋势等可视化内容"
+2 -1
View File
@@ -54,7 +54,8 @@
"view": {
"message": "訊息",
"interaction": "互動分析",
"ranking": "榜單"
"ranking": "榜單",
"relationship": "關係"
},
"quotes": {
"wordcloud": "詞雲",
+66
View File
@@ -55,6 +55,72 @@
"graphHint": "共 {nodes} 位成員,{links} 條互動關係",
"noInteraction": "暫無艾特互動資料"
},
"relationship": {
"loading": "正在分析關係動態…",
"noIndex": {
"title": "需要對話索引",
"description": "關係分析依賴對話索引資料,請先在總覽頁面生成對話索引。"
},
"empty": {
"title": "暫無關係資料",
"description": "目前時間範圍內沒有對話記錄。"
},
"overview": {
"title": "整體概覽"
},
"monthly": {
"title": "月度明細"
},
"trend": {
"title": "主動性趨勢",
"hint": "藍色線為 {name} 的月度主動發起佔比,50% 線為均衡基準"
},
"initiator": "發起話題",
"closer": "結束話題",
"initiated": "發起 {count} 次",
"closed": "結束 {count} 次",
"totalSessions": "共 {count} 次對話",
"times": "次",
"noActivity": "本月無互動",
"monthFormat": "{year}年{month}月",
"monthDetail": "發起 {init} / 結束 {close}",
"iceBreaker": {
"title": "破冰達人",
"hint": "超過 24 小時沉默後主動打破僵局的次數",
"count": "{count} 次破冰",
"total": "共 {count} 次破冰"
},
"responseLatency": {
"title": "回覆時延",
"hint": "收到對方訊息後平均多久回覆",
"count": "{count} 次回覆",
"seconds": "{n} 秒",
"minutes": "{n} 分鐘",
"hours": "{n} 小時",
"hoursMinutes": "{h} 小時 {m} 分鐘"
},
"perseverance": {
"title": "鍥而不捨",
"hint": "對方還沒回覆就又發了一則訊息的次數",
"hintWithThreshold": "對方{threshold}內還沒回覆就又發了一次訊息的次數",
"thresholdMinutes": "{n}分鐘",
"count": "{count} 次",
"total": "共 {count} 次",
"threshold": "間隔",
"empty": "在目前閾值下沒有鍥而不捨的記錄"
},
"labels": {
"mutualPursuit": "雙向奔赴",
"silentGuardian": "默默守候",
"devotedHeart": "一往情深",
"distantBond": "若即若離",
"wellMatched": "勢均力敵",
"missingYou": "{name} 在想念",
"monologue": "{name} 的獨白",
"peacefulSilence": "各自安好",
"fleetingThoughts": "偶爾想起"
}
},
"timeline": {
"title": "時間軸檢視",
"description": "以視覺化方式呈現訊息時間分布與活躍週期變化"
@@ -4,6 +4,7 @@ import { useI18n } from 'vue-i18n'
import { SubTabs } from '@/components/UI'
import UserSelect from '@/components/common/UserSelect.vue'
import MessageView from '@openchatlab/chart-message/MessageView.vue'
import RelationshipView from './view/RelationshipView.vue'
const { t } = useI18n()
@@ -17,14 +18,14 @@ const props = defineProps<{
timeFilter?: TimeFilter
}>()
// 子 Tab 配置(私聊只有消息视图)
const subTabs = computed(() => [
{ id: 'message', label: t('analysis.subTabs.view.message'), icon: 'i-heroicons-chat-bubble-left-right' },
{ id: 'relationship', label: t('analysis.subTabs.view.relationship'), icon: 'i-heroicons-heart' },
])
const activeSubTab = ref('message')
// 成员筛选
// 成员筛选(仅用于消息视图)
const selectedMemberId = ref<number | null>(null)
// 构建 timeFilter(含 memberId)
@@ -39,7 +40,7 @@ const viewTimeFilter = computed(() => ({
<!-- 子 Tab 导航(右侧插槽放成员筛选) -->
<SubTabs v-model="activeSubTab" :items="subTabs" persist-key="privateViewTab">
<template #right>
<UserSelect v-model="selectedMemberId" :session-id="props.sessionId" />
<UserSelect v-if="activeSubTab === 'message'" v-model="selectedMemberId" :session-id="props.sessionId" />
</template>
</SubTabs>
@@ -47,6 +48,11 @@ const viewTimeFilter = computed(() => ({
<div class="flex-1 min-h-0 overflow-auto">
<Transition name="fade" mode="out-in">
<MessageView v-if="activeSubTab === 'message'" :session-id="props.sessionId" :time-filter="viewTimeFilter" />
<RelationshipView
v-else-if="activeSubTab === 'relationship'"
:session-id="props.sessionId"
:time-filter="props.timeFilter"
/>
</Transition>
</div>
</div>
@@ -0,0 +1,74 @@
<script setup lang="ts">
import { computed, useSlots } from 'vue'
withDefaults(
defineProps<{
title: string
iconName: string
iconBgClass: string
iconColorClass: string
leftName?: string
leftValue?: string | number
rightName?: string
rightValue?: string | number
valueClass?: string
description?: string
}>(),
{
leftName: '—',
leftValue: '--',
rightName: '—',
rightValue: '--',
valueClass: 'text-gray-700 dark:text-gray-200',
description: '',
}
)
const slots = useSlots()
const hasHeaderExtra = computed(() => Boolean(slots['header-extra']))
</script>
<template>
<div
class="flex flex-col rounded-2xl bg-white p-4 ring-1 ring-gray-900/5 transition-colors dark:bg-transparent dark:ring-white/10"
>
<div class="mb-2 min-h-8">
<div class="flex items-center justify-between gap-2">
<div class="flex min-w-0 items-center gap-2">
<div class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full" :class="iconBgClass">
<UIcon :name="iconName" class="h-3.5 w-3.5" :class="iconColorClass" />
</div>
<span class="truncate text-xs font-bold tracking-wide text-gray-900 dark:text-white">{{ title }}</span>
</div>
<div v-if="hasHeaderExtra" class="shrink-0">
<slot name="header-extra" />
</div>
</div>
</div>
<div class="flex h-full flex-col">
<div class="grid grid-cols-2 gap-2">
<div class="flex min-w-0 flex-col items-center rounded-xl bg-white px-1 py-1.5 text-center dark:bg-transparent">
<div class="w-full truncate text-[10px] font-semibold tracking-wide text-gray-500 dark:text-gray-400" :title="leftName">
{{ leftName }}
</div>
<div class="mt-1 font-black font-mono leading-none tabular-nums" :class="valueClass">
{{ leftValue }}
</div>
</div>
<div class="flex min-w-0 flex-col items-center rounded-xl bg-white px-1 py-1.5 text-center dark:bg-transparent">
<div class="w-full truncate text-[10px] font-semibold tracking-wide text-gray-500 dark:text-gray-400" :title="rightName">
{{ rightName }}
</div>
<div class="mt-1 font-black font-mono leading-none tabular-nums" :class="valueClass">
{{ rightValue }}
</div>
</div>
</div>
<p class="mt-auto pt-3 min-h-8 text-[10px] leading-relaxed text-gray-500 dark:text-gray-400 sm:min-h-6">
{{ description }}
</p>
</div>
</div>
</template>
@@ -0,0 +1,662 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import type { RelationshipStats, RelationshipMonthStats } from '@/types/analysis'
import { SectionCard, EmptyState, LoadingState } from '@/components/UI'
import { EChart } from '@/components/charts'
import RelationshipMetricCard from './RelationshipMetricCard.vue'
import type { EChartsOption } from 'echarts'
const { t } = useI18n()
interface TimeFilter {
startTs?: number
endTs?: number
}
const props = defineProps<{
sessionId: string
timeFilter?: TimeFilter
}>()
const stats = ref<RelationshipStats | null>(null)
const isLoading = ref(false)
const isPerseveranceLoading = ref(false)
// 锲而不舍阈值(秒),默认 300(5分钟)
const perseveranceThreshold = ref(300)
async function loadData(options?: { localOnly?: 'perseverance' }) {
if (!props.sessionId) return
const isPerseveranceOnly = options?.localOnly === 'perseverance'
if (isPerseveranceOnly) {
isPerseveranceLoading.value = true
} else {
isLoading.value = true
}
try {
stats.value = await window.chatApi.getRelationshipStats(props.sessionId, props.timeFilter, {
perseveranceThreshold: perseveranceThreshold.value,
})
} catch (error) {
console.error('Failed to load relationship stats:', error)
} finally {
if (isPerseveranceOnly) {
isPerseveranceLoading.value = false
} else {
isLoading.value = false
}
}
}
watch(
() => [props.sessionId, props.timeFilter],
() => loadData(),
{ immediate: true, deep: true }
)
// 阈值预设选项
const thresholdOptions = [
{ label: '1m', value: 60 },
{ label: '3m', value: 180 },
{ label: '5m', value: 300 },
{ label: '10m', value: 600 },
{ label: '30m', value: 1800 },
]
function onThresholdChange(val: number) {
if (perseveranceThreshold.value === val) return
perseveranceThreshold.value = val
loadData({ localOnly: 'perseverance' })
}
const perseveranceThresholdModel = computed({
get: () => perseveranceThreshold.value,
set: (val: number) => onThresholdChange(val),
})
function getPerseveranceThresholdText(seconds: number): string {
return t('views.relationship.perseverance.thresholdMinutes', { n: Math.round(seconds / 60) })
}
const perseveranceHintText = computed(() =>
t('views.relationship.perseverance.hintWithThreshold', {
threshold: getPerseveranceThresholdText(perseveranceThreshold.value),
})
)
const hasData = computed(() => stats.value?.hasSessionIndex && stats.value.totalSessions > 0)
const memberA = computed(() => stats.value?.members[0])
const memberB = computed(() => stats.value?.members[1])
// ==================== 发起者 ====================
const overallInitiateRatio = computed(() => {
if (!memberA.value || !stats.value) return 50
const totalInit = (memberA.value.totalInitiateCount ?? 0) + (memberB.value?.totalInitiateCount ?? 0)
if (totalInit === 0) return 50
return Math.round((memberA.value.totalInitiateCount / totalInit) * 100)
})
const timeRangeString = computed(() => {
if (!stats.value || stats.value.months.length === 0) return ''
const sorted = [...stats.value.months].sort((a, b) => a.month.localeCompare(b.month))
const first = sorted[0].month
const last = sorted[sorted.length - 1].month
if (first === last) return `在 ${first.replace('-', '年')}月 中,`
return `在 ${first.replace('-', '年')}月 至 ${last.replace('-', '年')}月 期间,`
})
function getOverallLabel(): string {
if (!stats.value || stats.value.totalSessions <= 3) {
return t('views.relationship.labels.distantBond')
}
const ratio = overallInitiateRatio.value
if (ratio >= 40 && ratio <= 60) return t('views.relationship.labels.mutualPursuit')
if (ratio >= 80 || ratio <= 20) return t('views.relationship.labels.devotedHeart')
return t('views.relationship.labels.silentGuardian')
}
// ==================== 收尾者 ====================
const overallCloseRatio = computed(() => {
if (!memberA.value || !stats.value) return 50
const totalClose = (memberA.value.totalCloseCount ?? 0) + (memberB.value?.totalCloseCount ?? 0)
if (totalClose === 0) return 50
return Math.round((memberA.value.totalCloseCount / totalClose) * 100)
})
// 确保进度条视觉上可区分(至少 5% 最小宽度)
function clampBarWidth(ratio: number): number {
if (ratio <= 0) return 0
if (ratio >= 100) return 100
return Math.max(5, Math.min(95, ratio))
}
// ==================== 趋势折线图 ====================
const trendChartOption = computed<EChartsOption>(() => {
if (!stats.value || stats.value.months.length === 0 || !memberA.value) return {}
const sortedMonths = [...stats.value.months].sort((a, b) => a.month.localeCompare(b.month))
const xData = sortedMonths.map((m) => formatMonthShort(m.month))
const aName = memberA.value.name
const bName = memberB.value?.name ?? '—'
const aInitData = sortedMonths.map((m) => {
const aStats = m.members.find((mem) => mem.memberId === memberA.value!.memberId)
if (!aStats || m.totalSessions === 0) return 50
return Math.round((aStats.initiateCount / m.totalSessions) * 100)
})
return {
tooltip: {
trigger: 'axis',
formatter(params: any) {
const p = params[0]
const val = p.value as number
return `${p.axisValue}<br/>${aName}: ${val}%<br/>${bName}: ${100 - val}%`
},
},
grid: { left: 40, right: 20, top: 20, bottom: 30 },
xAxis: { type: 'category', data: xData, axisLabel: { fontSize: 11 } },
yAxis: {
type: 'value',
min: 0,
max: 100,
axisLabel: { formatter: '{value}%', fontSize: 11 },
splitLine: { lineStyle: { type: 'dashed' } },
},
series: [
{
type: 'line',
data: aInitData,
smooth: true,
symbol: 'circle',
symbolSize: 6,
lineStyle: { width: 2, color: '#3b82f6' },
itemStyle: { color: '#3b82f6' },
areaStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: 'rgba(59, 130, 246, 0.15)' },
{ offset: 1, color: 'rgba(59, 130, 246, 0)' },
],
},
},
markLine: {
silent: true,
data: [{ yAxis: 50, lineStyle: { type: 'dashed', color: '#9ca3af' } }],
label: { show: false },
symbol: 'none',
},
},
],
}
})
// ==================== 月度 helpers ====================
function getMonthLabel(month: RelationshipMonthStats): string {
if (month.totalSessions === 0) return t('views.relationship.labels.peacefulSilence')
if (month.totalSessions <= 2) return t('views.relationship.labels.fleetingThoughts')
const sorted = [...month.members].sort((a, b) => b.initiateCount - a.initiateCount)
const top = sorted[0]
if (!top || month.totalSessions === 0) return ''
const ratio = Math.round((top.initiateCount / month.totalSessions) * 100)
if (ratio >= 40 && ratio <= 60) return t('views.relationship.labels.wellMatched')
if (ratio >= 80) return t('views.relationship.labels.monologue', { name: top.name })
return t('views.relationship.labels.missingYou', { name: top.name })
}
function getMonthInitiateRatio(month: RelationshipMonthStats): number {
if (!memberA.value || month.totalSessions === 0) return 50
const aStats = month.members.find((m) => m.memberId === memberA.value!.memberId)
return Math.round(((aStats?.initiateCount ?? 0) / month.totalSessions) * 100)
}
function getMonthCloseRatio(month: RelationshipMonthStats): number {
if (!memberA.value) return 50
const aStats = month.members.find((m) => m.memberId === memberA.value!.memberId)
const aClose = aStats?.closeCount ?? 0
const totalClose = month.members.reduce((sum, m) => sum + (m.closeCount ?? 0), 0)
if (totalClose === 0) return 50
return Math.round((aClose / totalClose) * 100)
}
function formatMonth(monthStr: string): string {
const [year, month] = monthStr.split('-')
return t('views.relationship.monthFormat', { year, month: parseInt(month) })
}
function formatMonthShort(monthStr: string): string {
const parts = monthStr.split('-')
return `${parts[1]}月`
}
function getIceBreakCount(memberId?: number): number {
if (!memberId || !stats.value?.iceBreakers?.length) return 0
return stats.value.iceBreakers.filter((ib) => ib.memberId === memberId).reduce((sum, ib) => sum + ib.count, 0)
}
function getAvgResponseTime(memberId?: number): number | null {
if (!memberId || !stats.value?.responseLatency?.length) return null
const item = stats.value.responseLatency.find((rl) => rl.memberId === memberId)
return item ? item.avgResponseTime : null
}
function getPerseveranceCount(memberId?: number): number {
if (!memberId || !stats.value?.perseverance?.length) return 0
const item = stats.value.perseverance.find((p) => p.memberId === memberId)
return item?.totalDoubleTexts ?? 0
}
function formatResponseByMember(memberId?: number): string {
const avg = getAvgResponseTime(memberId)
return avg != null ? formatDuration(avg) : '--'
}
// 月度响应时延
function getMonthResponseLatency(month: string) {
return stats.value?.monthlyResponseLatency?.find((m) => m.month === month)?.members ?? []
}
// 月度锲而不舍
function getMonthPerseverance(month: string) {
return stats.value?.monthlyPerseverance?.find((m) => m.month === month)?.members ?? []
}
function getMemberResponseLatencyText(month: string, memberId?: number): string {
if (!memberId) return '--'
const rl = getMonthResponseLatency(month).find((r) => r.memberId === memberId)
return rl ? formatDuration(rl.avgResponseTime) : '--'
}
function getMemberPerseveranceText(month: string, memberId?: number): string {
if (!memberId) return '--'
const p = getMonthPerseverance(month).find((p) => p.memberId === memberId)
return p ? p.doubleTextCount.toString() : '--'
}
function formatDuration(seconds: number): string {
if (seconds < 60) return t('views.relationship.responseLatency.seconds', { n: seconds })
if (seconds < 3600) return t('views.relationship.responseLatency.minutes', { n: Math.round(seconds / 60) })
const hours = Math.floor(seconds / 3600)
const mins = Math.round((seconds % 3600) / 60)
if (mins === 0) return t('views.relationship.responseLatency.hours', { n: hours })
return t('views.relationship.responseLatency.hoursMinutes', { h: hours, m: mins })
}
</script>
<template>
<div class="main-content mx-auto max-w-[920px] space-y-6 p-6">
<LoadingState v-if="isLoading" :text="t('views.relationship.loading')" />
<!-- 无会话索引 -->
<EmptyState
v-else-if="stats && !stats.hasSessionIndex"
icon="i-heroicons-clock"
:title="t('views.relationship.noIndex.title')"
:description="t('views.relationship.noIndex.description')"
/>
<!-- 无数据 -->
<EmptyState
v-else-if="stats && !hasData"
icon="i-heroicons-heart"
:title="t('views.relationship.empty.title')"
:description="t('views.relationship.empty.description')"
/>
<!-- 有数据 -->
<template v-else-if="stats && hasData">
<div class="space-y-6">
<!-- 关系大满贯 (Single Page Shareable Poster) -->
<div
id="shareable-poster"
class="relative flex flex-col overflow-hidden rounded-[24px] bg-white shadow-xl ring-1 ring-gray-900/5 dark:bg-card-dark dark:ring-white/10"
>
<!-- 全局分享图背景装饰 -->
<div class="pointer-events-none absolute inset-0 overflow-hidden">
<div
class="absolute -left-[20%] -top-[20%] h-[70%] w-[70%] rounded-full bg-blue-400/10 blur-[80px] dark:bg-blue-500/20"
/>
<div
class="absolute -right-[20%] top-[10%] h-[70%] w-[70%] rounded-full bg-pink-400/10 blur-[80px] dark:bg-pink-500/20"
/>
</div>
<!-- 1. 主视觉区域 (Primary Module) -->
<div
class="relative z-10 flex flex-col items-center justify-center gap-10 px-6 pt-10 pb-6 sm:px-8 lg:flex-row lg:gap-16 xl:gap-24"
>
<!-- 左侧:文字描述与基础数据 -->
<div class="flex shrink-0 flex-col items-center justify-center">
<div class="flex w-fit flex-col items-start text-left">
<div class="flex flex-col text-[15px] leading-relaxed text-gray-600 dark:text-gray-300">
<p class="mb-2 text-sm font-medium tracking-wide text-gray-500 dark:text-gray-400">
{{ timeRangeString }}
</p>
<div class="mb-4 flex items-baseline gap-2">
<span class="text-xl font-medium text-gray-700 dark:text-gray-300">你们共进行了</span>
<span class="font-black text-5xl tracking-tight text-gray-900 dark:text-white">
{{ stats.totalSessions }}
</span>
<span class="text-xl font-medium text-gray-700 dark:text-gray-300">次对话</span>
</div>
<div class="flex items-baseline flex-wrap gap-x-1.5 gap-y-1">
<span class="text-base font-medium text-gray-600 dark:text-gray-300">其中有</span>
<span class="font-black text-3xl text-pink-500 dark:text-pink-400">
{{ overallInitiateRatio >= 50 ? overallInitiateRatio : 100 - overallInitiateRatio }}%
</span>
<span class="text-base font-medium text-gray-600 dark:text-gray-300">的开场白由</span>
<span class="font-bold text-xl text-gray-900 dark:text-white">
{{ overallInitiateRatio >= 50 ? memberA?.name : memberB?.name }}
</span>
<span class="text-base font-medium text-gray-600 dark:text-gray-300">发起</span>
</div>
</div>
<!-- VS Stats -->
<div class="mt-8 flex w-full max-w-[320px] items-center justify-between gap-4">
<div class="flex flex-1 flex-col items-center overflow-hidden">
<div
class="w-full truncate text-center text-xs font-bold text-gray-500 dark:text-gray-400"
:title="memberA?.name"
>
{{ memberA?.name }}
</div>
<div class="mt-1 text-2xl font-black text-blue-500 dark:text-blue-400">
{{ memberA?.totalInitiateCount }}
</div>
<div class="mt-0.5 text-[10px] font-medium text-gray-400">次发起</div>
</div>
<div
class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-gray-100 font-black text-[10px] italic text-gray-400 shadow-inner dark:bg-gray-900/80 dark:text-gray-500"
>
VS
</div>
<div class="flex flex-1 flex-col items-center overflow-hidden">
<div
class="w-full truncate text-center text-xs font-bold text-gray-500 dark:text-gray-400"
:title="memberB?.name"
>
{{ memberB?.name }}
</div>
<div class="mt-1 text-2xl font-black text-pink-500 dark:text-pink-400">
{{ memberB?.totalInitiateCount }}
</div>
<div class="mt-0.5 text-[10px] font-medium text-gray-400">次发起</div>
</div>
</div>
</div>
</div>
<!-- 右侧:主动性趋势图 -->
<div v-if="stats.months.length >= 2" class="flex w-full max-w-[400px] shrink-0 flex-col justify-center">
<div class="mb-2 flex items-center justify-between px-1">
<span class="text-sm font-bold text-gray-900 dark:text-white">
{{ t('views.relationship.trend.title') }}
</span>
<span class="text-[10px] font-medium text-gray-500 dark:text-gray-400">
{{ t('views.relationship.trend.hint', { name: memberA?.name ?? '' }) }}
</span>
</div>
<div class="w-full">
<div class="h-[200px] w-full">
<EChart :option="trendChartOption" :height="200" />
</div>
</div>
</div>
</div>
<!-- 2. 四个次要模块 (4 Secondary Modules) -->
<div class="relative z-10 grid grid-cols-2 gap-3 p-4 pt-2 sm:p-6 sm:pt-2 lg:grid-cols-4">
<RelationshipMetricCard
title="话题终结者"
icon-name="i-heroicons-hand-raised-solid"
icon-bg-class="bg-blue-100 dark:bg-blue-500/20"
icon-color-class="text-blue-600 dark:text-blue-400"
:left-name="memberA?.name"
:left-value="memberA?.totalCloseCount ?? 0"
:right-name="memberB?.name"
:right-value="memberB?.totalCloseCount ?? 0"
value-class="text-lg text-blue-600 dark:text-blue-400"
description="统计结束话题的次数。"
/>
<RelationshipMetricCard
:title="t('views.relationship.iceBreaker.title')"
icon-name="i-heroicons-fire-solid"
icon-bg-class="bg-pink-100 dark:bg-pink-500/20"
icon-color-class="text-pink-600 dark:text-pink-400"
:left-name="memberA?.name"
:left-value="getIceBreakCount(memberA?.memberId)"
:right-name="memberB?.name"
:right-value="getIceBreakCount(memberB?.memberId)"
value-class="text-lg text-pink-600 dark:text-pink-400"
:description="t('views.relationship.iceBreaker.hint')"
/>
<RelationshipMetricCard
:title="t('views.relationship.responseLatency.title')"
icon-name="i-heroicons-clock-solid"
icon-bg-class="bg-amber-100 dark:bg-amber-500/20"
icon-color-class="text-amber-600 dark:text-amber-400"
:left-name="memberA?.name"
:left-value="formatResponseByMember(memberA?.memberId)"
:right-name="memberB?.name"
:right-value="formatResponseByMember(memberB?.memberId)"
value-class="text-sm text-amber-600 dark:text-amber-400"
:description="t('views.relationship.responseLatency.hint')"
/>
<RelationshipMetricCard
:title="t('views.relationship.perseverance.title')"
icon-name="i-heroicons-arrow-path-solid"
icon-bg-class="bg-purple-100 dark:bg-purple-500/20"
icon-color-class="text-purple-600 dark:text-purple-400"
:left-name="memberA?.name"
:left-value="getPerseveranceCount(memberA?.memberId)"
:right-name="memberB?.name"
:right-value="getPerseveranceCount(memberB?.memberId)"
value-class="text-lg text-purple-600 dark:text-purple-400"
:description="perseveranceHintText"
>
<template #header-extra>
<USelect
v-model="perseveranceThresholdModel"
:items="thresholdOptions"
value-key="value"
size="xs"
class="relative z-[120] w-16"
:ui="{ content: 'z-[121]' }"
:disabled="isPerseveranceLoading"
/>
</template>
</RelationshipMetricCard>
</div>
<!-- Share Footer / Watermark -->
<div
class="relative z-10 flex items-center justify-between px-6 pb-4 opacity-40 mix-blend-luminosity dark:opacity-30 sm:px-8 sm:pb-5"
>
<div class="flex items-center gap-1.5">
<UIcon name="i-heroicons-chat-bubble-left-right-solid" class="h-3.5 w-3.5" />
<span class="text-[10px] font-bold uppercase tracking-wider">ChatLab</span>
</div>
<span class="text-[9px] font-medium uppercase tracking-widest">Relationship Report</span>
</div>
</div>
</div>
<!-- 月度时间线 -->
<SectionCard :title="t('views.relationship.monthly.title')" :show-divider="false">
<div class="p-6 sm:p-8">
<div class="relative ml-4 border-l-2 border-gray-100/80 py-4 dark:border-gray-800/80">
<div v-for="(month, idx) in stats.months" :key="month.month" class="relative pb-12 pl-10 last:pb-0">
<!-- Timeline Dot -->
<div
class="absolute -left-[11px] top-1 flex h-5 w-5 items-center justify-center rounded-full bg-white ring-4 ring-white dark:bg-gray-900 dark:ring-gray-900"
>
<div
class="h-2.5 w-2.5 rounded-full shadow-sm"
:class="
month.totalSessions > 0
? idx === 0
? 'bg-pink-500 animate-pulse'
: 'bg-pink-400'
: 'bg-gray-300 dark:bg-gray-600'
"
/>
</div>
<!-- Month Content Card -->
<div
class="group relative overflow-hidden rounded-[20px] bg-white shadow-sm ring-1 ring-gray-900/5 transition-all hover:-translate-y-0.5 hover:shadow-md dark:bg-card-dark dark:ring-white/10"
>
<!-- 装饰背景 -->
<div
v-if="month.totalSessions > 0"
class="pointer-events-none absolute inset-0 overflow-hidden opacity-30 transition-opacity group-hover:opacity-50"
>
<div
class="absolute -left-[10%] top-[20%] h-[60%] w-[40%] rounded-full bg-blue-400/10 blur-[40px] dark:bg-blue-500/20"
/>
<div
class="absolute -right-[10%] bottom-[20%] h-[60%] w-[40%] rounded-full bg-pink-400/10 blur-[40px] dark:bg-pink-500/20"
/>
</div>
<div class="relative z-10 p-4 sm:p-5">
<!-- 头部:月份与总次数 -->
<div class="flex items-center justify-between" :class="month.totalSessions > 0 ? 'mb-4' : ''">
<div class="flex items-center gap-3">
<span class="text-xl font-bold tracking-tight text-gray-900 dark:text-white">
{{ formatMonth(month.month) }}
</span>
<span
class="rounded-lg px-2.5 py-1 text-xs font-semibold"
:class="
month.totalSessions === 0
? 'bg-gray-50 text-gray-500 dark:bg-gray-800/50 dark:text-gray-400'
: 'bg-pink-50 text-pink-600 dark:bg-pink-500/10 dark:text-pink-400'
"
>
{{ getMonthLabel(month) }}
</span>
</div>
<div v-if="month.totalSessions > 0" class="flex items-baseline gap-1.5">
<span class="text-xs font-medium text-gray-500 dark:text-gray-400">共对话</span>
<span class="text-2xl font-black leading-none text-gray-900 dark:text-white">{{ month.totalSessions }}</span>
<span class="text-xs font-medium text-gray-500 dark:text-gray-400">次</span>
</div>
</div>
<template v-if="month.totalSessions > 0">
<!-- 内容区:四列高度压缩卡片网格 -->
<div class="grid grid-cols-2 gap-3 lg:grid-cols-4">
<!-- 1. 发起者 -->
<div class="flex flex-col rounded-xl bg-blue-50/50 p-3 ring-1 ring-blue-100/50 dark:bg-blue-500/5 dark:ring-blue-500/10">
<div class="mb-2.5 flex items-center gap-1.5">
<UIcon name="i-heroicons-chat-bubble-bottom-center-text-solid" class="h-3.5 w-3.5 text-blue-500 dark:text-blue-400" />
<span class="text-xs font-bold text-gray-700 dark:text-gray-300">{{ t('views.relationship.initiator') }}</span>
</div>
<div class="flex flex-col gap-1.5">
<div class="flex items-center justify-between text-xs">
<span class="w-12 truncate font-medium text-gray-500 dark:text-gray-400">{{ memberA?.name }}</span>
<span class="font-black tabular-nums text-blue-600 dark:text-blue-400">{{ month.members.find(m => m.memberId === memberA?.memberId)?.initiateCount ?? 0 }}</span>
</div>
<div class="flex items-center justify-between text-xs">
<span class="w-12 truncate font-medium text-gray-500 dark:text-gray-400">{{ memberB?.name }}</span>
<span class="font-black tabular-nums text-pink-500 dark:text-pink-400">{{ month.members.find(m => m.memberId === memberB?.memberId)?.initiateCount ?? 0 }}</span>
</div>
</div>
<div class="mt-3 flex h-1 w-full overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
<div class="bg-blue-500 dark:bg-blue-400" :style="{ width: clampBarWidth(getMonthInitiateRatio(month)) + '%' }" />
<div class="bg-pink-500 dark:bg-pink-400" :style="{ width: clampBarWidth(100 - getMonthInitiateRatio(month)) + '%' }" />
</div>
</div>
<!-- 2. 终结者 -->
<div class="flex flex-col rounded-xl bg-indigo-50/50 p-3 ring-1 ring-indigo-100/50 dark:bg-indigo-500/5 dark:ring-indigo-500/10">
<div class="mb-2.5 flex items-center gap-1.5">
<UIcon name="i-heroicons-hand-raised-solid" class="h-3.5 w-3.5 text-indigo-500 dark:text-indigo-400" />
<span class="text-xs font-bold text-gray-700 dark:text-gray-300">{{ t('views.relationship.closer') }}</span>
</div>
<div class="flex flex-col gap-1.5">
<div class="flex items-center justify-between text-xs">
<span class="w-12 truncate font-medium text-gray-500 dark:text-gray-400">{{ memberA?.name }}</span>
<span class="font-black tabular-nums text-indigo-600 dark:text-indigo-400">{{ month.members.find(m => m.memberId === memberA?.memberId)?.closeCount ?? 0 }}</span>
</div>
<div class="flex items-center justify-between text-xs">
<span class="w-12 truncate font-medium text-gray-500 dark:text-gray-400">{{ memberB?.name }}</span>
<span class="font-black tabular-nums text-pink-500 dark:text-pink-400">{{ month.members.find(m => m.memberId === memberB?.memberId)?.closeCount ?? 0 }}</span>
</div>
</div>
<div class="mt-3 flex h-1 w-full overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
<div class="bg-indigo-500 dark:bg-indigo-400" :style="{ width: clampBarWidth(getMonthCloseRatio(month)) + '%' }" />
<div class="bg-pink-500 dark:bg-pink-400" :style="{ width: clampBarWidth(100 - getMonthCloseRatio(month)) + '%' }" />
</div>
</div>
<!-- 3. 响应时延 -->
<div class="flex flex-col rounded-xl bg-amber-50/50 p-3 ring-1 ring-amber-100/50 dark:bg-amber-500/5 dark:ring-amber-500/10">
<div class="mb-2.5 flex items-center gap-1.5">
<UIcon name="i-heroicons-clock-solid" class="h-3.5 w-3.5 text-amber-500 dark:text-amber-400" />
<span class="text-xs font-bold text-gray-700 dark:text-gray-300">{{ t('views.relationship.responseLatency.title') }}</span>
</div>
<div class="flex flex-col gap-1.5">
<div class="flex items-center justify-between text-xs">
<span class="w-12 truncate font-medium text-gray-500 dark:text-gray-400">{{ memberA?.name }}</span>
<span class="font-black tabular-nums text-amber-600 dark:text-amber-500">{{ getMemberResponseLatencyText(month.month, memberA?.memberId) }}</span>
</div>
<div class="flex items-center justify-between text-xs">
<span class="w-12 truncate font-medium text-gray-500 dark:text-gray-400">{{ memberB?.name }}</span>
<span class="font-black tabular-nums text-amber-600 dark:text-amber-500">{{ getMemberResponseLatencyText(month.month, memberB?.memberId) }}</span>
</div>
</div>
</div>
<!-- 4. 锲而不舍 -->
<div class="flex flex-col rounded-xl bg-purple-50/50 p-3 ring-1 ring-purple-100/50 dark:bg-purple-500/5 dark:ring-purple-500/10">
<div class="mb-2.5 flex items-center gap-1.5">
<UIcon name="i-heroicons-arrow-path-solid" class="h-3.5 w-3.5 text-purple-500 dark:text-purple-400" />
<span class="text-xs font-bold text-gray-700 dark:text-gray-300">{{ t('views.relationship.perseverance.title') }}</span>
</div>
<div class="flex flex-col gap-1.5">
<div class="flex items-center justify-between text-xs">
<span class="w-12 truncate font-medium text-gray-500 dark:text-gray-400">{{ memberA?.name }}</span>
<span class="font-black tabular-nums text-purple-600 dark:text-purple-400">{{ getMemberPerseveranceText(month.month, memberA?.memberId) }}</span>
</div>
<div class="flex items-center justify-between text-xs">
<span class="w-12 truncate font-medium text-gray-500 dark:text-gray-400">{{ memberB?.name }}</span>
<span class="font-black tabular-nums text-purple-600 dark:text-purple-400">{{ getMemberPerseveranceText(month.month, memberB?.memberId) }}</span>
</div>
</div>
</div>
</div>
</template>
<div v-else class="flex h-24 items-center justify-center text-sm font-medium text-gray-400 dark:text-gray-500">
{{ t('views.relationship.noActivity') }}
</div>
</div>
</div>
</div>
</div>
</div>
</SectionCard>
</template>
</div>
</template>
@@ -392,9 +392,10 @@ export function useAIConfigForm(props: {
const finalName = generateName()
const isReasoning = formData.value.isReasoningModel
const persistCustomModels = isCompatMode.value && compatModels.value.length > 0
? compatModels.value.map((m) => ({ id: m.id, name: m.name }))
: undefined
const persistCustomModels =
isCompatMode.value && compatModels.value.length > 0
? compatModels.value.map((m) => ({ id: m.id, name: m.name }))
: undefined
if (props.mode.value === 'add') {
const result = await window.llmApi.addConfig({
name: finalName,
+72
View File
@@ -576,3 +576,75 @@ export interface ClusterGraphData {
communities: ClusterGraphCommunity[]
stats: ClusterGraphStats
}
// ==================== 关系主动性分析(私聊) ====================
export interface RelationshipMonthStats {
month: string
members: Array<{
memberId: number
name: string
initiateCount: number
closeCount: number
}>
totalSessions: number
}
export interface IceBreakerItem {
month: string
memberId: number
name: string
count: number
}
export interface ResponseLatencyMember {
memberId: number
name: string
avgResponseTime: number
totalResponses: number
}
export interface PerseveranceMember {
memberId: number
name: string
totalDoubleTexts: number
}
export interface MonthlyResponseLatency {
month: string
members: Array<{
memberId: number
name: string
avgResponseTime: number
responseCount: number
}>
}
export interface MonthlyPerseverance {
month: string
members: Array<{
memberId: number
name: string
doubleTextCount: number
}>
}
export interface RelationshipStats {
months: RelationshipMonthStats[]
members: Array<{
memberId: number
name: string
totalInitiateCount: number
totalCloseCount: number
}>
totalSessions: number
hasSessionIndex: boolean
iceBreakers: IceBreakerItem[]
totalIceBreaks: number
responseLatency: ResponseLatencyMember[]
perseverance: PerseveranceMember[]
totalDoubleTexts: number
monthlyResponseLatency: MonthlyResponseLatency[]
monthlyPerseverance: MonthlyPerseverance[]
perseveranceThreshold: number
}
+13
View File
@@ -0,0 +1,13 @@
const LOCALE_PATH_MAP: Record<string, string> = {
'en-US': 'en',
'zh-TW': 'zh-TW',
'ja-JP': 'ja',
}
/**
* 将应用 locale 转为 chatlab.fun 站点的路径前缀。
* zh-CN 为默认语言,返回空字符串(无前缀)。
*/
export function getChatlabSiteLocalePath(locale: string): string {
return LOCALE_PATH_MAP[locale] ?? ''
}