feat(admin): add conversation management with device tracking display

## Backend (conversation-service)
- Add AdminConversationController with JWT auth for admin API
- Endpoints: list conversations, by user, detail, messages, statistics
- Support filtering by status, userId, date range, conversion
- Add JWT_SECRET environment variable to docker-compose.yml
- Add jsonwebtoken dependency for admin token verification

## Frontend (admin-client)
### New Features:
- Add conversations feature module with:
  - API layer (conversations.api.ts)
  - React Query hooks (useConversations.ts)
  - ConversationsPage with full management UI

### User Management Enhancement:
- Add "最近咨询记录" section in user detail drawer
- Display device info for each conversation:
  - IP address with region
  - User-Agent (parsed to browser/OS)
  - Device fingerprint
- Show conversation status, conversion status, message count

### Navigation:
- Add "对话管理" menu item with MessageOutlined icon
- Add /conversations route

## Files Added:
- admin-conversation.controller.ts (backend admin API)
- conversations feature folder (frontend)
  - infrastructure/conversations.api.ts
  - application/useConversations.ts
  - presentation/pages/ConversationsPage.tsx

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-01-25 10:04:17 -08:00
parent 6a3a2130bf
commit 931055b51f
16 changed files with 1138 additions and 3 deletions

View File

@ -290,6 +290,7 @@ services:
ANTHROPIC_BASE_URL: ${ANTHROPIC_BASE_URL:-https://api.anthropic.com}
KNOWLEDGE_SERVICE_URL: http://knowledge-service:3003
CORS_ORIGINS: https://iconsulting.szaiai.com,http://localhost:5173
JWT_SECRET: ${JWT_SECRET:-your-jwt-secret-key}
ports:
- "3004:3004"
healthcheck:

View File

@ -7,6 +7,7 @@ import { KnowledgePage } from './features/knowledge/presentation/pages/Knowledge
import { ExperiencePage } from './features/experience/presentation/pages/ExperiencePage';
import { AnalyticsPage, ReportsPage, AuditPage } from './features/analytics';
import { UsersPage } from './features/users';
import { ConversationsPage } from './features/conversations';
import { SettingsPage } from './features/settings';
function App() {
@ -31,6 +32,7 @@ function App() {
<Route path="reports" element={<ReportsPage />} />
<Route path="audit" element={<AuditPage />} />
<Route path="users" element={<UsersPage />} />
<Route path="conversations" element={<ConversationsPage />} />
<Route path="settings" element={<SettingsPage />} />
</Route>

View File

@ -0,0 +1 @@
export * from './useConversations';

View File

@ -0,0 +1,66 @@
import { useQuery } from '@tanstack/react-query';
import {
conversationsApi,
ConversationQueryParams,
ConversationDto,
ConversationListItem,
MessageDto,
PaginatedConversations,
ConversationStatistics,
} from '../infrastructure/conversations.api';
// Conversation list query
export function useConversations(params: ConversationQueryParams) {
return useQuery<PaginatedConversations>({
queryKey: ['conversations', 'list', params],
queryFn: () => conversationsApi.listConversations(params),
});
}
// Conversations by user query
export function useUserConversations(userId: string, limit = 10, enabled = true) {
return useQuery<ConversationListItem[]>({
queryKey: ['conversations', 'by-user', userId, limit],
queryFn: () => conversationsApi.getConversationsByUser(userId, limit),
enabled: enabled && !!userId,
});
}
// Conversation detail query
export function useConversationDetail(id: string, enabled = true) {
return useQuery<ConversationDto>({
queryKey: ['conversations', 'detail', id],
queryFn: () => conversationsApi.getConversation(id),
enabled: enabled && !!id,
});
}
// Conversation messages query
export function useConversationMessages(id: string, enabled = true) {
return useQuery<MessageDto[]>({
queryKey: ['conversations', 'messages', id],
queryFn: () => conversationsApi.getConversationMessages(id),
enabled: enabled && !!id,
});
}
// Conversation statistics query
export function useConversationStatistics() {
return useQuery<ConversationStatistics>({
queryKey: ['conversations', 'statistics'],
queryFn: () => conversationsApi.getStatistics(),
refetchInterval: 60000, // Refresh every minute
});
}
// Re-export types
export type {
ConversationDto,
ConversationListItem,
ConversationStatus,
DeviceInfo,
MessageDto,
PaginatedConversations,
ConversationStatistics,
ConversationQueryParams,
} from '../infrastructure/conversations.api';

View File

@ -0,0 +1,3 @@
export * from './application';
export * from './infrastructure';
export * from './presentation/pages';

View File

@ -0,0 +1,127 @@
import api from '../../../shared/utils/api';
// ==================== DTOs ====================
export type ConversationStatus = 'ACTIVE' | 'ENDED' | 'ARCHIVED';
export interface DeviceInfo {
ip?: string;
userAgent?: string;
fingerprint?: string;
region?: string;
}
export interface ConversationDto {
id: string;
userId: string;
status: ConversationStatus;
title: string | null;
summary: string | null;
category: string | null;
messageCount: number;
userMessageCount: number;
assistantMessageCount: number;
totalInputTokens: number;
totalOutputTokens: number;
rating: number | null;
feedback: string | null;
hasConverted: boolean;
consultingStage: string | null;
consultingState?: Record<string, unknown> | null;
collectedInfo?: Record<string, unknown> | null;
recommendedPrograms?: string[] | null;
conversionPath?: string | null;
deviceInfo: DeviceInfo | null;
createdAt: string;
updatedAt: string;
endedAt: string | null;
}
export interface ConversationListItem {
id: string;
userId: string;
status: ConversationStatus;
title: string | null;
messageCount: number;
hasConverted: boolean;
consultingStage: string | null;
deviceInfo: DeviceInfo | null;
createdAt: string;
updatedAt: string;
endedAt: string | null;
}
export interface MessageDto {
id: string;
conversationId: string;
role: 'user' | 'assistant' | 'system';
type: string;
content: string;
metadata?: Record<string, unknown>;
createdAt: string;
}
export interface PaginatedConversations {
items: ConversationDto[];
total: number;
page: number;
pageSize: number;
totalPages: number;
}
export interface ConversationStatistics {
total: number;
active: number;
ended: number;
converted: number;
todayCount: number;
conversionRate: string;
}
export interface ConversationQueryParams {
page?: number;
pageSize?: number;
status?: ConversationStatus;
userId?: string;
startDate?: string;
endDate?: string;
hasConverted?: boolean;
sortBy?: 'createdAt' | 'updatedAt' | 'messageCount';
sortOrder?: 'ASC' | 'DESC';
}
// ==================== API ====================
export const conversationsApi = {
// List conversations with pagination
listConversations: async (params: ConversationQueryParams): Promise<PaginatedConversations> => {
const response = await api.get('/conversations/admin/list', { params });
return response.data.data;
},
// Get conversations by user ID
getConversationsByUser: async (userId: string, limit = 10): Promise<ConversationListItem[]> => {
const response = await api.get(`/conversations/admin/by-user/${userId}`, {
params: { limit },
});
return response.data.data;
},
// Get conversation detail
getConversation: async (id: string): Promise<ConversationDto> => {
const response = await api.get(`/conversations/admin/${id}`);
return response.data.data;
},
// Get conversation messages
getConversationMessages: async (id: string): Promise<MessageDto[]> => {
const response = await api.get(`/conversations/admin/${id}/messages`);
return response.data.data;
},
// Get statistics
getStatistics: async (): Promise<ConversationStatistics> => {
const response = await api.get('/conversations/admin/statistics/overview');
return response.data.data;
},
};

View File

@ -0,0 +1 @@
export * from './conversations.api';

View File

@ -0,0 +1,502 @@
import { useState } from 'react';
import {
Card,
Table,
Tag,
Space,
Select,
DatePicker,
Row,
Col,
Statistic,
Typography,
Drawer,
Descriptions,
Spin,
List,
Tooltip,
} from 'antd';
import {
MessageOutlined,
CheckCircleOutlined,
GlobalOutlined,
LaptopOutlined,
BarChartOutlined,
} from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import dayjs from 'dayjs';
import {
useConversations,
useConversationStatistics,
useConversationDetail,
useConversationMessages,
type ConversationDto,
type ConversationStatus,
type ConversationQueryParams,
type MessageDto,
} from '../../application';
const { Title } = Typography;
const { RangePicker } = DatePicker;
const STATUS_COLORS: Record<ConversationStatus, string> = {
ACTIVE: 'green',
ENDED: 'default',
ARCHIVED: 'gray',
};
const STATUS_LABELS: Record<ConversationStatus, string> = {
ACTIVE: '进行中',
ENDED: '已结束',
ARCHIVED: '已归档',
};
export function ConversationsPage() {
const [filters, setFilters] = useState<ConversationQueryParams>({
page: 1,
pageSize: 20,
sortBy: 'createdAt',
sortOrder: 'DESC',
});
const [dateRange, setDateRange] = useState<[dayjs.Dayjs | null, dayjs.Dayjs | null] | null>(null);
const [detailDrawerOpen, setDetailDrawerOpen] = useState(false);
const [selectedConversationId, setSelectedConversationId] = useState<string | null>(null);
// Queries
const { data: conversationsData, isLoading: loadingConversations } = useConversations(filters);
const { data: stats, isLoading: loadingStats } = useConversationStatistics();
const { data: conversationDetail, isLoading: loadingDetail } = useConversationDetail(
selectedConversationId || '',
!!selectedConversationId && detailDrawerOpen
);
const { data: messages, isLoading: loadingMessages } = useConversationMessages(
selectedConversationId || '',
!!selectedConversationId && detailDrawerOpen
);
const showConversationDetail = (conv: ConversationDto) => {
setSelectedConversationId(conv.id);
setDetailDrawerOpen(true);
};
const handleDateRangeChange = (dates: [dayjs.Dayjs | null, dayjs.Dayjs | null] | null) => {
setDateRange(dates);
if (dates && dates[0] && dates[1]) {
setFilters((prev) => ({
...prev,
startDate: dates[0]!.format('YYYY-MM-DD'),
endDate: dates[1]!.format('YYYY-MM-DD'),
page: 1,
}));
} else {
setFilters((prev) => ({
...prev,
startDate: undefined,
endDate: undefined,
page: 1,
}));
}
};
const handleTableChange = (pagination: { current?: number; pageSize?: number }) => {
setFilters((prev) => ({
...prev,
page: pagination.current || 1,
pageSize: pagination.pageSize || 20,
}));
};
const columns: ColumnsType<ConversationDto> = [
{
title: '对话',
key: 'conversation',
render: (_, record) => (
<div>
<div className="font-medium">{record.title || '未命名对话'}</div>
<div className="text-xs text-gray-500">ID: {record.id.slice(0, 8)}...</div>
</div>
),
},
{
title: '用户',
dataIndex: 'userId',
key: 'userId',
width: 120,
render: (userId) => (
<Tooltip title={userId}>
<code className="text-xs">{userId.slice(0, 8)}...</code>
</Tooltip>
),
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 100,
render: (status: ConversationStatus) => (
<Tag color={STATUS_COLORS[status]}>{STATUS_LABELS[status]}</Tag>
),
},
{
title: '消息数',
dataIndex: 'messageCount',
key: 'messageCount',
width: 80,
align: 'center',
},
{
title: '转化',
dataIndex: 'hasConverted',
key: 'hasConverted',
width: 80,
align: 'center',
render: (converted) =>
converted ? (
<CheckCircleOutlined style={{ color: '#52c41a' }} />
) : (
<span className="text-gray-300">-</span>
),
},
{
title: '访问IP',
key: 'ip',
width: 140,
render: (_, record) => record.deviceInfo?.ip || '-',
},
{
title: '设备',
key: 'device',
width: 140,
render: (_, record) => {
if (!record.deviceInfo?.userAgent) return '-';
return (
<Tooltip title={record.deviceInfo.userAgent}>
<span>{parseUserAgent(record.deviceInfo.userAgent)}</span>
</Tooltip>
);
},
},
{
title: '创建时间',
dataIndex: 'createdAt',
key: 'createdAt',
width: 160,
render: (date) => dayjs(date).format('YYYY-MM-DD HH:mm'),
},
{
title: '操作',
key: 'action',
width: 80,
render: (_, record) => <a onClick={() => showConversationDetail(record)}></a>,
},
];
return (
<div className="p-6">
<Title level={4} className="mb-6"></Title>
{/* Statistics Cards */}
<Row gutter={[16, 16]} className="mb-4">
<Col xs={12} sm={6}>
<Card>
<Spin spinning={loadingStats}>
<Statistic
title="总对话数"
value={stats?.total ?? 0}
prefix={<MessageOutlined />}
valueStyle={{ color: '#1890ff' }}
/>
</Spin>
</Card>
</Col>
<Col xs={12} sm={6}>
<Card>
<Spin spinning={loadingStats}>
<Statistic
title="今日对话"
value={stats?.todayCount ?? 0}
prefix={<BarChartOutlined />}
valueStyle={{ color: '#722ed1' }}
/>
</Spin>
</Card>
</Col>
<Col xs={12} sm={6}>
<Card>
<Spin spinning={loadingStats}>
<Statistic
title="已转化"
value={stats?.converted ?? 0}
prefix={<CheckCircleOutlined />}
valueStyle={{ color: '#52c41a' }}
/>
<div className="mt-2 text-gray-500 text-sm">
: {stats?.conversionRate ?? '0'}%
</div>
</Spin>
</Card>
</Col>
<Col xs={12} sm={6}>
<Card>
<Spin spinning={loadingStats}>
<Statistic
title="进行中"
value={stats?.active ?? 0}
prefix={<MessageOutlined />}
valueStyle={{ color: '#faad14' }}
/>
</Spin>
</Card>
</Col>
</Row>
{/* Filters */}
<Card className="mb-4">
<Space wrap>
<Select
placeholder="状态"
allowClear
value={filters.status}
onChange={(value) => setFilters((prev) => ({ ...prev, status: value, page: 1 }))}
style={{ width: 120 }}
options={[
{ value: 'ACTIVE', label: '进行中' },
{ value: 'ENDED', label: '已结束' },
{ value: 'ARCHIVED', label: '已归档' },
]}
/>
<Select
placeholder="转化状态"
allowClear
value={filters.hasConverted}
onChange={(value) => setFilters((prev) => ({ ...prev, hasConverted: value, page: 1 }))}
style={{ width: 120 }}
options={[
{ value: true, label: '已转化' },
{ value: false, label: '未转化' },
]}
/>
<RangePicker
value={dateRange}
onChange={handleDateRangeChange}
placeholder={['开始日期', '结束日期']}
/>
<Select
value={filters.sortBy}
onChange={(value) => setFilters((prev) => ({ ...prev, sortBy: value }))}
style={{ width: 120 }}
options={[
{ value: 'createdAt', label: '创建时间' },
{ value: 'updatedAt', label: '更新时间' },
{ value: 'messageCount', label: '消息数' },
]}
/>
<Select
value={filters.sortOrder}
onChange={(value) => setFilters((prev) => ({ ...prev, sortOrder: value }))}
style={{ width: 100 }}
options={[
{ value: 'DESC', label: '降序' },
{ value: 'ASC', label: '升序' },
]}
/>
</Space>
</Card>
{/* Conversations Table */}
<Card>
<Spin spinning={loadingConversations}>
<Table
columns={columns}
dataSource={conversationsData?.items || []}
rowKey="id"
pagination={{
current: conversationsData?.page || 1,
pageSize: conversationsData?.pageSize || 20,
total: conversationsData?.total || 0,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total) => `${total}`,
}}
onChange={handleTableChange}
scroll={{ x: 1200 }}
/>
</Spin>
</Card>
{/* Conversation Detail Drawer */}
<Drawer
title="对话详情"
placement="right"
width={600}
open={detailDrawerOpen}
onClose={() => {
setDetailDrawerOpen(false);
setSelectedConversationId(null);
}}
>
<Spin spinning={loadingDetail}>
{conversationDetail && (
<div>
{/* Basic Info */}
<Descriptions bordered column={2} size="small" className="mb-4">
<Descriptions.Item label="对话ID" span={2}>
<code className="text-xs">{conversationDetail.id}</code>
</Descriptions.Item>
<Descriptions.Item label="标题" span={2}>
{conversationDetail.title || '-'}
</Descriptions.Item>
<Descriptions.Item label="用户ID">
<code className="text-xs">{conversationDetail.userId.slice(0, 12)}...</code>
</Descriptions.Item>
<Descriptions.Item label="状态">
<Tag color={STATUS_COLORS[conversationDetail.status]}>
{STATUS_LABELS[conversationDetail.status]}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="消息数">
{conversationDetail.messageCount}
</Descriptions.Item>
<Descriptions.Item label="转化">
{conversationDetail.hasConverted ? (
<Tag color="green"></Tag>
) : (
<Tag></Tag>
)}
</Descriptions.Item>
<Descriptions.Item label="创建时间">
{dayjs(conversationDetail.createdAt).format('YYYY-MM-DD HH:mm:ss')}
</Descriptions.Item>
<Descriptions.Item label="结束时间">
{conversationDetail.endedAt
? dayjs(conversationDetail.endedAt).format('YYYY-MM-DD HH:mm:ss')
: '-'}
</Descriptions.Item>
</Descriptions>
{/* Device Info */}
{conversationDetail.deviceInfo && (
<>
<Title level={5}>
<Space>
<LaptopOutlined />
</Space>
</Title>
<Descriptions bordered column={1} size="small" className="mb-4">
<Descriptions.Item label="访问IP">
<Space>
<GlobalOutlined />
{conversationDetail.deviceInfo.ip || '-'}
{conversationDetail.deviceInfo.region && (
<Tag>{conversationDetail.deviceInfo.region}</Tag>
)}
</Space>
</Descriptions.Item>
<Descriptions.Item label="User-Agent">
<Tooltip title={conversationDetail.deviceInfo.userAgent}>
<span className="text-xs">
{conversationDetail.deviceInfo.userAgent
? parseUserAgent(conversationDetail.deviceInfo.userAgent)
: '-'}
</span>
</Tooltip>
</Descriptions.Item>
<Descriptions.Item label="设备指纹">
<code className="text-xs">
{conversationDetail.deviceInfo.fingerprint || '-'}
</code>
</Descriptions.Item>
</Descriptions>
</>
)}
{/* Token Usage */}
<Title level={5}>
<Space>
<BarChartOutlined />
Token 使
</Space>
</Title>
<Row gutter={16} className="mb-4">
<Col span={12}>
<Card size="small">
<Statistic
title="输入 Tokens"
value={conversationDetail.totalInputTokens}
valueStyle={{ fontSize: 18 }}
/>
</Card>
</Col>
<Col span={12}>
<Card size="small">
<Statistic
title="输出 Tokens"
value={conversationDetail.totalOutputTokens}
valueStyle={{ fontSize: 18 }}
/>
</Card>
</Col>
</Row>
{/* Messages */}
<Title level={5}>
<Space>
<MessageOutlined />
</Space>
</Title>
<Spin spinning={loadingMessages}>
<List
size="small"
dataSource={messages || []}
style={{ maxHeight: 400, overflow: 'auto' }}
renderItem={(msg: MessageDto) => (
<List.Item
className={msg.role === 'user' ? 'bg-blue-50' : 'bg-gray-50'}
style={{ borderRadius: 4, marginBottom: 8 }}
>
<div className="w-full px-2">
<div className="flex justify-between items-center mb-1">
<Tag color={msg.role === 'user' ? 'blue' : 'green'}>
{msg.role === 'user' ? '用户' : '助手'}
</Tag>
<span className="text-xs text-gray-400">
{dayjs(msg.createdAt).format('HH:mm:ss')}
</span>
</div>
<div className="text-sm whitespace-pre-wrap">{msg.content}</div>
</div>
</List.Item>
)}
/>
</Spin>
</div>
)}
</Spin>
</Drawer>
</div>
);
}
// Helper function to parse User-Agent into readable format
function parseUserAgent(ua: string): string {
if (!ua) return '-';
let browser = 'Unknown';
let os = 'Unknown';
if (ua.includes('Chrome')) browser = 'Chrome';
else if (ua.includes('Firefox')) browser = 'Firefox';
else if (ua.includes('Safari')) browser = 'Safari';
else if (ua.includes('Edge')) browser = 'Edge';
if (ua.includes('Windows')) os = 'Windows';
else if (ua.includes('Mac')) os = 'macOS';
else if (ua.includes('Linux')) os = 'Linux';
else if (ua.includes('Android')) os = 'Android';
else if (ua.includes('iPhone') || ua.includes('iPad')) os = 'iOS';
return `${browser} / ${os}`;
}

View File

@ -0,0 +1 @@
export * from './ConversationsPage';

View File

@ -14,12 +14,19 @@ import {
Descriptions,
Spin,
Avatar,
Divider,
List,
Tooltip,
} from 'antd';
import {
UserOutlined,
TeamOutlined,
UserAddOutlined,
SearchOutlined,
MessageOutlined,
GlobalOutlined,
LaptopOutlined,
CheckCircleOutlined,
} from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import dayjs from 'dayjs';
@ -30,6 +37,7 @@ import {
type UserDto,
type UserType,
} from '../../application';
import { useUserConversations, type ConversationListItem } from '../../../conversations/application';
const { Title } = Typography;
const { Search } = Input;
@ -71,6 +79,11 @@ export function UsersPage() {
selectedUserId || '',
!!selectedUserId && detailDrawerOpen
);
const { data: userConversations, isLoading: loadingConversations } = useUserConversations(
selectedUserId || '',
10,
!!selectedUserId && detailDrawerOpen
);
const showUserDetail = (user: UserDto) => {
setSelectedUserId(user.id);
@ -269,7 +282,7 @@ export function UsersPage() {
<Drawer
title="用户详情"
placement="right"
width={480}
width={520}
open={detailDrawerOpen}
onClose={() => {
setDetailDrawerOpen(false);
@ -319,6 +332,84 @@ export function UsersPage() {
{dayjs(userDetail.lastActiveAt).format('YYYY-MM-DD HH:mm:ss')}
</Descriptions.Item>
</Descriptions>
{/* Recent Conversations */}
<Divider orientation="left">
<Space>
<MessageOutlined />
</Space>
</Divider>
<Spin spinning={loadingConversations}>
{userConversations && userConversations.length > 0 ? (
<List
size="small"
dataSource={userConversations}
renderItem={(conv: ConversationListItem) => (
<List.Item>
<div className="w-full">
<div className="flex justify-between items-start mb-2">
<div className="flex-1 min-w-0">
<div className="font-medium truncate">
{conv.title || '未命名对话'}
</div>
<div className="text-xs text-gray-500">
{dayjs(conv.createdAt).format('YYYY-MM-DD HH:mm')}
</div>
</div>
<Space size={4}>
<Tag color={conv.status === 'ACTIVE' ? 'green' : 'default'}>
{conv.status === 'ACTIVE' ? '进行中' : '已结束'}
</Tag>
{conv.hasConverted && (
<Tooltip title="已转化">
<CheckCircleOutlined style={{ color: '#52c41a' }} />
</Tooltip>
)}
</Space>
</div>
{/* Device Info */}
{conv.deviceInfo && (
<div className="text-xs text-gray-400 space-y-1">
{conv.deviceInfo.ip && (
<div className="flex items-center gap-1">
<GlobalOutlined />
<span>IP: {conv.deviceInfo.ip}</span>
{conv.deviceInfo.region && (
<span className="text-gray-300">({conv.deviceInfo.region})</span>
)}
</div>
)}
{conv.deviceInfo.userAgent && (
<Tooltip title={conv.deviceInfo.userAgent}>
<div className="flex items-center gap-1 truncate">
<LaptopOutlined />
<span className="truncate">{parseUserAgent(conv.deviceInfo.userAgent)}</span>
</div>
</Tooltip>
)}
{conv.deviceInfo.fingerprint && (
<div className="flex items-center gap-1">
<span>: </span>
<code className="text-xs">{conv.deviceInfo.fingerprint.slice(0, 12)}...</code>
</div>
)}
</div>
)}
<div className="text-xs text-gray-500 mt-1">
: {conv.messageCount}
</div>
</div>
</List.Item>
)}
/>
) : (
<div className="text-center text-gray-400 py-4"></div>
)}
</Spin>
</div>
)}
</Spin>
@ -326,3 +417,25 @@ export function UsersPage() {
</div>
);
}
// Helper function to parse User-Agent into readable format
function parseUserAgent(ua: string): string {
if (!ua) return '-';
// Simple parsing - extract browser and OS
let browser = 'Unknown';
let os = 'Unknown';
if (ua.includes('Chrome')) browser = 'Chrome';
else if (ua.includes('Firefox')) browser = 'Firefox';
else if (ua.includes('Safari')) browser = 'Safari';
else if (ua.includes('Edge')) browser = 'Edge';
if (ua.includes('Windows')) os = 'Windows';
else if (ua.includes('Mac')) os = 'macOS';
else if (ua.includes('Linux')) os = 'Linux';
else if (ua.includes('Android')) os = 'Android';
else if (ua.includes('iPhone') || ua.includes('iPad')) os = 'iOS';
return `${browser} / ${os}`;
}

View File

@ -15,6 +15,7 @@ import {
FileTextOutlined,
AuditOutlined,
LineChartOutlined,
MessageOutlined,
} from '@ant-design/icons';
import { useAuth } from '../hooks/useAuth';
@ -64,6 +65,11 @@ const menuItems: MenuProps['items'] = [
icon: <UserOutlined />,
label: '用户管理',
},
{
key: '/conversations',
icon: <MessageOutlined />,
label: '对话管理',
},
{
key: '/settings',
icon: <SettingOutlined />,

View File

@ -38,9 +38,11 @@
"rxjs": "^7.8.0",
"socket.io": "^4.8.3",
"typeorm": "^0.3.19",
"uuid": "^9.0.0"
"uuid": "^9.0.0",
"jsonwebtoken": "^9.0.0"
},
"devDependencies": {
"@types/jsonwebtoken": "^9.0.0",
"@nestjs/cli": "^10.0.0",
"@nestjs/testing": "^10.0.0",
"@types/express": "^4.17.21",

View File

@ -0,0 +1,302 @@
import {
Controller,
Get,
Query,
Param,
Headers,
UnauthorizedException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import * as jwt from 'jsonwebtoken';
import { ConversationORM } from '../../infrastructure/database/postgres/entities/conversation.orm';
import { MessageORM } from '../../infrastructure/database/postgres/entities/message.orm';
interface AdminPayload {
id: string;
username: string;
role: string;
}
/**
* API - admin-client 使
* JWT
*/
@Controller('conversations/admin')
export class AdminConversationController {
constructor(
@InjectRepository(ConversationORM)
private conversationRepo: Repository<ConversationORM>,
@InjectRepository(MessageORM)
private messageRepo: Repository<MessageORM>,
) {}
/**
* Token
*/
private verifyAdmin(authorization: string): AdminPayload {
const token = authorization?.replace('Bearer ', '');
if (!token) {
throw new UnauthorizedException('Missing token');
}
try {
const secret = process.env.JWT_SECRET || 'your-jwt-secret-key';
const payload = jwt.verify(token, secret) as AdminPayload;
return payload;
} catch {
throw new UnauthorizedException('Invalid token');
}
}
/**
*
*/
@Get('list')
async listConversations(
@Headers('authorization') auth: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
@Query('status') status?: string,
@Query('userId') userId?: string,
@Query('startDate') startDate?: string,
@Query('endDate') endDate?: string,
@Query('hasConverted') hasConverted?: string,
@Query('sortBy') sortBy?: string,
@Query('sortOrder') sortOrder?: string,
) {
this.verifyAdmin(auth);
const pageNum = parseInt(page || '1');
const pageSizeNum = parseInt(pageSize || '20');
const queryBuilder = this.conversationRepo.createQueryBuilder('c');
// 应用筛选条件
if (status) {
queryBuilder.andWhere('c.status = :status', { status });
}
if (userId) {
queryBuilder.andWhere('c.user_id = :userId', { userId });
}
if (startDate) {
queryBuilder.andWhere('c.created_at >= :startDate', {
startDate: new Date(startDate),
});
}
if (endDate) {
queryBuilder.andWhere('c.created_at <= :endDate', {
endDate: new Date(endDate),
});
}
if (hasConverted !== undefined) {
queryBuilder.andWhere('c.has_converted = :hasConverted', {
hasConverted: hasConverted === 'true',
});
}
// 排序
const sortColumnMap: Record<string, string> = {
createdAt: 'c.created_at',
updatedAt: 'c.updated_at',
messageCount: 'c.message_count',
};
const validSortBy = sortBy && sortColumnMap[sortBy] ? sortBy : 'createdAt';
const validSortOrder = sortOrder === 'ASC' ? 'ASC' : 'DESC';
queryBuilder.orderBy(sortColumnMap[validSortBy], validSortOrder);
// 获取总数
const total = await queryBuilder.getCount();
// 分页
queryBuilder.skip((pageNum - 1) * pageSizeNum).take(pageSizeNum);
const conversations = await queryBuilder.getMany();
return {
success: true,
data: {
items: conversations.map((c) => ({
id: c.id,
userId: c.userId,
status: c.status,
title: c.title,
summary: c.summary,
category: c.category,
messageCount: c.messageCount,
userMessageCount: c.userMessageCount,
assistantMessageCount: c.assistantMessageCount,
totalInputTokens: c.totalInputTokens,
totalOutputTokens: c.totalOutputTokens,
rating: c.rating,
feedback: c.feedback,
hasConverted: c.hasConverted,
consultingStage: c.consultingStage,
deviceInfo: c.deviceInfo,
createdAt: c.createdAt,
updatedAt: c.updatedAt,
endedAt: c.endedAt,
})),
total,
page: pageNum,
pageSize: pageSizeNum,
totalPages: Math.ceil(total / pageSizeNum),
},
};
}
/**
*
*/
@Get('by-user/:userId')
async getConversationsByUser(
@Headers('authorization') auth: string,
@Param('userId') userId: string,
@Query('limit') limit?: string,
) {
this.verifyAdmin(auth);
const limitNum = parseInt(limit || '10');
const conversations = await this.conversationRepo.find({
where: { userId },
order: { createdAt: 'DESC' },
take: limitNum,
});
return {
success: true,
data: conversations.map((c) => ({
id: c.id,
userId: c.userId,
status: c.status,
title: c.title,
messageCount: c.messageCount,
hasConverted: c.hasConverted,
consultingStage: c.consultingStage,
deviceInfo: c.deviceInfo,
createdAt: c.createdAt,
updatedAt: c.updatedAt,
endedAt: c.endedAt,
})),
};
}
/**
*
*/
@Get(':id')
async getConversation(
@Headers('authorization') auth: string,
@Param('id') id: string,
) {
this.verifyAdmin(auth);
const conversation = await this.conversationRepo.findOne({
where: { id },
});
if (!conversation) {
return {
success: false,
error: 'Conversation not found',
};
}
return {
success: true,
data: {
id: conversation.id,
userId: conversation.userId,
status: conversation.status,
title: conversation.title,
summary: conversation.summary,
category: conversation.category,
messageCount: conversation.messageCount,
userMessageCount: conversation.userMessageCount,
assistantMessageCount: conversation.assistantMessageCount,
totalInputTokens: conversation.totalInputTokens,
totalOutputTokens: conversation.totalOutputTokens,
rating: conversation.rating,
feedback: conversation.feedback,
hasConverted: conversation.hasConverted,
consultingStage: conversation.consultingStage,
consultingState: conversation.consultingState,
collectedInfo: conversation.collectedInfo,
recommendedPrograms: conversation.recommendedPrograms,
conversionPath: conversation.conversionPath,
deviceInfo: conversation.deviceInfo,
createdAt: conversation.createdAt,
updatedAt: conversation.updatedAt,
endedAt: conversation.endedAt,
},
};
}
/**
*
*/
@Get(':id/messages')
async getConversationMessages(
@Headers('authorization') auth: string,
@Param('id') conversationId: string,
) {
this.verifyAdmin(auth);
const messages = await this.messageRepo.find({
where: { conversationId },
order: { createdAt: 'ASC' },
});
return {
success: true,
data: messages.map((m) => ({
id: m.id,
conversationId: m.conversationId,
role: m.role,
type: m.type,
content: m.content,
metadata: m.metadata,
createdAt: m.createdAt,
})),
};
}
/**
*
*/
@Get('statistics/overview')
async getStatistics(@Headers('authorization') auth: string) {
this.verifyAdmin(auth);
const total = await this.conversationRepo.count();
const active = await this.conversationRepo.count({ where: { status: 'ACTIVE' } });
const ended = await this.conversationRepo.count({ where: { status: 'ENDED' } });
const converted = await this.conversationRepo.count({ where: { hasConverted: true } });
// 今日对话
const today = new Date();
today.setHours(0, 0, 0, 0);
const todayCount = await this.conversationRepo
.createQueryBuilder('c')
.where('c.created_at >= :today', { today })
.getCount();
return {
success: true,
data: {
total,
active,
ended,
converted,
todayCount,
conversionRate: total > 0 ? ((converted / total) * 100).toFixed(1) : '0',
},
};
}
}

View File

@ -1,3 +1,4 @@
export * from './conversation.controller';
export * from './conversation.gateway';
export * from './internal.controller';
export * from './admin-conversation.controller';

View File

@ -12,11 +12,12 @@ import { TOKEN_USAGE_REPOSITORY } from '../domain/repositories/token-usage.repos
import { ConversationService } from '../application/services/conversation.service';
import { ConversationController } from '../adapters/inbound/conversation.controller';
import { InternalConversationController } from '../adapters/inbound/internal.controller';
import { AdminConversationController } from '../adapters/inbound/admin-conversation.controller';
import { ConversationGateway } from '../adapters/inbound/conversation.gateway';
@Module({
imports: [TypeOrmModule.forFeature([ConversationORM, MessageORM, TokenUsageORM])],
controllers: [ConversationController, InternalConversationController],
controllers: [ConversationController, InternalConversationController, AdminConversationController],
providers: [
ConversationService,
ConversationGateway,

View File

@ -138,6 +138,9 @@ importers:
ioredis:
specifier: ^5.3.0
version: 5.9.1
jsonwebtoken:
specifier: ^9.0.0
version: 9.0.3
kafkajs:
specifier: ^2.2.4
version: 2.2.4
@ -169,6 +172,9 @@ importers:
'@types/jest':
specifier: ^29.5.0
version: 29.5.14
'@types/jsonwebtoken':
specifier: ^9.0.0
version: 9.0.10
'@types/node':
specifier: ^20.10.0
version: 20.19.27