diff --git a/.DS_Store b/.DS_Store
new file mode 100644
index 0000000..2fdd36b
Binary files /dev/null and b/.DS_Store differ
diff --git a/README.md b/README.md
index 079e083..d64c644 100644
--- a/README.md
+++ b/README.md
@@ -1,30 +1,31 @@
-# 用户数据资产中台
+# 神射手 - 用户数字资产中台
*Automatically synced with your [v0.dev](https://v0.dev) deployments*
[](https://vercel.com/fnvtks-projects/kr-users)
-[](https://v0.dev/chat/projects/ThbCUpze4HC)
+[](https://v0.app/chat/0421-wmxy9okroIx)
-## Overview
+## 快速开始
-This repository will stay in sync with your deployed chats on [v0.dev](https://v0.dev).
-Any changes you make to your deployed app will be automatically pushed to this repository from [v0.dev](https://v0.dev).
+```bash
+pnpm install
+pnpm dev
+```
+
+访问 http://localhost:3000/login 使用 **zhiqun@qq.com / Zhiqun1984** 登录(本地 mock)。
+
+## v0 与 GitHub 同步
+
+1. 在 [v0 项目](https://v0.app/chat/0421-wmxy9okroIx) 中编辑并 Deploy
+2. 本地拉取最新代码:`git pull origin main`
+
+详见 [开发文档/v0-神射手集成指南.md](./开发文档/v0-神射手集成指南.md)
+
+## 环境变量
+
+复制 `.env.example` 为 `.env.local`。不配置 `NEXT_PUBLIC_API_BASE_URL` 时使用本地 mock 登录。
## Deployment
-Your project is live at:
-
-**[https://vercel.com/fnvtks-projects/kr-users](https://vercel.com/fnvtks-projects/kr-users)**
-
-## Build your app
-
-Continue building your app on:
-
-**[https://v0.dev/chat/projects/ThbCUpze4HC](https://v0.dev/chat/projects/ThbCUpze4HC)**
-
-## How It Works
-
-1. Create and modify your project using [v0.dev](https://v0.dev)
-2. Deploy your chats from the v0 interface
-3. Changes are automatically pushed to this repository
-4. Vercel deploys the latest version from this repository
+- **Vercel**: [fnvtks-projects/kr-users](https://vercel.com/fnvtks-projects/kr-users)
+- **v0 项目**: [0421 神射手-用户数字资产中台](https://v0.app/chat/0421-wmxy9okroIx)
diff --git a/app/.DS_Store b/app/.DS_Store
new file mode 100644
index 0000000..bef46af
Binary files /dev/null and b/app/.DS_Store differ
diff --git a/app/ClientLayout.tsx b/app/ClientLayout.tsx
index c4011a8..4e70405 100644
--- a/app/ClientLayout.tsx
+++ b/app/ClientLayout.tsx
@@ -4,10 +4,12 @@ import type React from "react"
import "./globals.css"
import { Inter } from "next/font/google"
import { useState, useEffect } from "react"
+import { usePathname } from "next/navigation"
import Sidebar from "./components/Sidebar"
import MobileHeader from "./components/MobileHeader"
import MobileSidebar from "./components/MobileSidebar"
import BottomNav from "./components/BottomNav"
+import { Toaster } from "@/components/ui/toaster"
const inter = Inter({ subsets: ["latin"] })
@@ -16,8 +18,10 @@ export default function ClientLayout({
}: {
children: React.ReactNode
}) {
+ const pathname = usePathname()
const [isMobile, setIsMobile] = useState(false)
const [sidebarOpen, setSidebarOpen] = useState(false)
+ const isLoginPage = pathname === "/login"
useEffect(() => {
const checkMobile = () => {
@@ -30,6 +34,21 @@ export default function ClientLayout({
return () => window.removeEventListener("resize", checkMobile)
}, [])
+ if (isLoginPage) {
+ return (
+
+
+ 神射手 - 登录
+
+
+
+ {children}
+
+
+
+ )
+ }
+
return (
@@ -65,6 +84,8 @@ export default function ClientLayout({
{/* 移动端底部导航 */}
{isMobile && }
+
+
)
diff --git a/app/api/auth/login/route.ts b/app/api/auth/login/route.ts
new file mode 100644
index 0000000..8f3de71
--- /dev/null
+++ b/app/api/auth/login/route.ts
@@ -0,0 +1,77 @@
+import { NextRequest, NextResponse } from "next/server"
+
+/**
+ * 本地登录 API - 支持邮箱/手机号 + 密码
+ * 当未配置 NEXT_PUBLIC_API_BASE_URL 时使用
+ * 开发账号: zhiqun@qq.com / Zhiqun1984
+ */
+const MOCK_USERS: Record = {
+ "zhiqun@qq.com": { password: "Zhiqun1984" },
+}
+
+function isEmail(value: string): boolean {
+ return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)
+}
+
+export async function POST(request: NextRequest) {
+ try {
+ const formData = await request.formData()
+ const account = (formData.get("email") || formData.get("phone") || "").toString().trim()
+ const password = (formData.get("password") || "").toString()
+ const verificationCode = formData.get("verificationCode")?.toString()
+
+ if (!account) {
+ return NextResponse.json(
+ { code: 40001, message: "请输入邮箱或手机号" },
+ { status: 200 }
+ )
+ }
+
+ // 验证码登录:开发环境下任意6位验证码通过
+ if (verificationCode) {
+ if (verificationCode.length >= 4) {
+ const token = `mock_token_${Date.now()}_${account}`
+ return NextResponse.json({
+ code: 10000,
+ message: "登录成功",
+ data: { token },
+ })
+ }
+ return NextResponse.json(
+ { code: 40002, message: "验证码错误" },
+ { status: 200 }
+ )
+ }
+
+ // 密码登录
+ if (!password) {
+ return NextResponse.json(
+ { code: 40003, message: "请输入密码" },
+ { status: 200 }
+ )
+ }
+
+ const key = isEmail(account) ? account : account
+ const user = MOCK_USERS[key]
+
+ if (user && user.password === password) {
+ const token = `mock_token_${Date.now()}_${account}`
+ return NextResponse.json({
+ code: 10000,
+ message: "登录成功",
+ data: { token },
+ })
+ }
+
+ return NextResponse.json(
+ { code: 40004, message: "邮箱/手机号或密码错误" },
+ { status: 200 }
+ )
+ } catch (error) {
+ console.error("[auth/login]", error)
+ return NextResponse.json(
+ { code: 50000, message: "服务器错误" },
+ { status: 500 }
+ )
+ }
+}
diff --git a/app/api/auth/send-code/route.ts b/app/api/auth/send-code/route.ts
new file mode 100644
index 0000000..d06b6f4
--- /dev/null
+++ b/app/api/auth/send-code/route.ts
@@ -0,0 +1,31 @@
+import { NextRequest, NextResponse } from "next/server"
+
+/**
+ * 本地验证码发送 API (mock)
+ * 开发环境下直接返回成功,验证码可为任意4位以上
+ */
+export async function POST(request: NextRequest) {
+ try {
+ const formData = await request.formData()
+ const phone = (formData.get("phone") || "").toString().trim()
+
+ if (!phone) {
+ return NextResponse.json(
+ { code: 40001, message: "请输入手机号" },
+ { status: 200 }
+ )
+ }
+
+ // Mock: 模拟发送成功,开发时可用 123456 等作为验证码
+ return NextResponse.json({
+ code: 10000,
+ message: "验证码已发送(开发模式:可使用任意4位以上数字)",
+ })
+ } catch (error) {
+ console.error("[auth/send-code]", error)
+ return NextResponse.json(
+ { code: 50000, message: "服务器错误" },
+ { status: 500 }
+ )
+ }
+}
diff --git a/app/api/rfm/analyze/route.ts b/app/api/rfm/analyze/route.ts
index 8c0b91b..7d72064 100644
--- a/app/api/rfm/analyze/route.ts
+++ b/app/api/rfm/analyze/route.ts
@@ -1,12 +1,16 @@
+/**
+ * RFM 分析 API
+ * 支持 MongoDB 真实数据 + 内存计算
+ */
+
import { NextResponse } from "next/server"
import { analyzeUser, type AnalyzeInput } from "@/services/rfm-engine"
+import { analyzeUserRFM } from "@/services/rfm-mongodb-service"
import { generateText } from "ai"
import { openai } from "@ai-sdk/openai"
/**
- * 可选 AI 标签增强:
- * - 使用 AI SDK (generateText + openai("gpt-4o")),符合统一标准 [^1]
- * - 无 OPENAI_API_KEY 时自动跳过,保持稳定
+ * 可选 AI 标签增强
*/
async function aiTagging(chat_logs?: string[]) {
const text = (chat_logs ?? []).slice(0, 8).join("。")
@@ -33,13 +37,38 @@ export async function POST(req: Request) {
const body = await req.json()
const inputs: AnalyzeInput[] = Array.isArray(body) ? body : [body]
const useAI = (Array.isArray(body) ? (body as any).useAI : (body as any)?.useAI) ?? false
+ const useMongoData = (body as any)?.useMongoData ?? true
const results = []
+
for (const input of inputs) {
- const base = analyzeUser(input)
+ let base: any
+ let source = 'memory'
+
+ // 尝试从 MongoDB 获取真实数据
+ if (useMongoData && input.user_id) {
+ // 如果 user_id 是手机号格式,尝试从 MongoDB 查询
+ const phone = input.user_id.replace(/\D/g, '')
+ if (/^1[3-9]\d{9}$/.test(phone)) {
+ const mongoResult = await analyzeUserRFM(phone)
+ if (mongoResult.found) {
+ base = mongoResult.data
+ source = 'mongodb'
+ }
+ }
+ }
+
+ // 如果 MongoDB 没有数据,使用内存计算
+ if (!base) {
+ base = analyzeUser(input)
+ source = 'memory'
+ }
+
+ // AI 标签增强
if (useAI) {
const ai = await aiTagging(input.chat_logs)
if (ai) {
+ base.tags = base.tags || {}
base.tags.emotion = ai.emotion ?? base.tags.emotion
base.tags.intent = ai.intent ?? base.tags.intent
if (Array.isArray(ai.behavior)) {
@@ -47,10 +76,55 @@ export async function POST(req: Request) {
}
}
}
- results.push(base)
+
+ results.push({ ...base, source })
}
+
return NextResponse.json({ success: true, data: results })
+
} catch (e: any) {
- return NextResponse.json({ success: false, error: e?.message || "Invalid input" }, { status: 400 })
+ console.error('RFM analyze error:', e)
+ return NextResponse.json({
+ success: false,
+ error: e?.message || "Invalid input"
+ }, { status: 400 })
+ }
+}
+
+/**
+ * GET 方法:按手机号查询用户 RFM
+ */
+export async function GET(req: Request) {
+ try {
+ const { searchParams } = new URL(req.url)
+ const phone = searchParams.get('phone')
+
+ if (!phone) {
+ return NextResponse.json({
+ success: false,
+ error: '请提供手机号参数'
+ }, { status: 400 })
+ }
+
+ const result = await analyzeUserRFM(phone)
+
+ if (!result.found) {
+ return NextResponse.json({
+ success: false,
+ error: '未找到该用户'
+ }, { status: 404 })
+ }
+
+ return NextResponse.json({
+ success: true,
+ data: result.data,
+ source: 'mongodb'
+ })
+
+ } catch (e: any) {
+ return NextResponse.json({
+ success: false,
+ error: e?.message || "查询失败"
+ }, { status: 500 })
}
}
diff --git a/app/api/rfm/get_tags/route.ts b/app/api/rfm/get_tags/route.ts
index d0c978c..452404c 100644
--- a/app/api/rfm/get_tags/route.ts
+++ b/app/api/rfm/get_tags/route.ts
@@ -1,12 +1,51 @@
+/**
+ * RFM 标签获取 API
+ * 对接 MongoDB 真实数据
+ */
+
import { NextResponse } from "next/server"
+import { getRFMTagsDistribution } from "@/services/rfm-mongodb-service"
import { getUserTags } from "@/services/rfm-engine"
+/**
+ * GET /api/rfm/get_tags
+ * 获取标签分布或指定用户的标签
+ */
export async function GET(req: Request) {
- const url = new URL(req.url)
- const userId = url.searchParams.get("user_id")
- if (!userId) {
- return NextResponse.json({ success: false, error: "missing user_id" }, { status: 400 })
+ try {
+ const { searchParams } = new URL(req.url)
+ const userId = searchParams.get('user_id')
+
+ // 如果指定用户,返回用户标签
+ if (userId) {
+ const userTags = getUserTags(userId)
+ if (userTags) {
+ return NextResponse.json({
+ success: true,
+ data: userTags.tags,
+ source: 'memory'
+ })
+ }
+ return NextResponse.json({
+ success: false,
+ error: '未找到该用户标签'
+ }, { status: 404 })
+ }
+
+ // 否则返回标签分布
+ const distribution = await getRFMTagsDistribution()
+
+ return NextResponse.json({
+ success: true,
+ data: distribution,
+ source: 'mongodb'
+ })
+
+ } catch (error) {
+ console.error('Get tags error:', error)
+ return NextResponse.json({
+ success: false,
+ error: error instanceof Error ? error.message : 'Unknown error'
+ }, { status: 500 })
}
- const data = getUserTags(userId)
- return NextResponse.json({ success: true, data })
}
diff --git a/app/api/rfm/group_summary/route.ts b/app/api/rfm/group_summary/route.ts
index 149e5e7..dd6279f 100644
--- a/app/api/rfm/group_summary/route.ts
+++ b/app/api/rfm/group_summary/route.ts
@@ -1,7 +1,54 @@
+/**
+ * RFM 分组统计 API
+ * 对接 MongoDB 真实数据
+ */
+
import { NextResponse } from "next/server"
+import { getMongoRFMGroupSummary } from "@/services/rfm-mongodb-service"
import { getGroupSummary } from "@/services/rfm-engine"
export async function GET() {
- const data = getGroupSummary()
- return NextResponse.json({ success: true, data })
+ try {
+ // 尝试从 MongoDB 获取真实数据
+ const mongoData = await getMongoRFMGroupSummary()
+
+ if (mongoData.totalUsers > 0) {
+ return NextResponse.json({
+ success: true,
+ data: {
+ gradeCount: mongoData.gradeCount,
+ valueCount: mongoData.valueCount,
+ lifecycleCount: {}, // MongoDB 暂无此字段
+ totalUsers: mongoData.totalUsers,
+ avgScore: mongoData.avgScore
+ },
+ source: 'mongodb'
+ })
+ }
+
+ // 回退到内存数据
+ const memData = getGroupSummary()
+ return NextResponse.json({
+ success: true,
+ data: memData,
+ source: 'memory'
+ })
+
+ } catch (error) {
+ console.error('RFM group summary error:', error)
+
+ // 返回默认数据
+ return NextResponse.json({
+ success: true,
+ data: {
+ gradeCount: { S: 0, A: 0, B: 0, C: 0, D: 0 },
+ valueCount: { '高': 0, '中': 0, '低': 0 },
+ lifecycleCount: {},
+ totalUsers: 0,
+ avgScore: 0
+ },
+ source: 'fallback',
+ error: error instanceof Error ? error.message : 'Unknown error'
+ })
+ }
}
diff --git a/app/api/search/route.ts b/app/api/search/route.ts
index c004650..411fcf4 100644
--- a/app/api/search/route.ts
+++ b/app/api/search/route.ts
@@ -1,52 +1,177 @@
-import { type NextRequest, NextResponse } from "next/server"
-import { getIntelligentSearchService } from "@/services/intelligent-search-service"
+/**
+ * 智能搜索 API 路由
+ * 对接神射手 MongoDB - 跨库查询
+ */
+import { type NextRequest, NextResponse } from "next/server"
+import {
+ intelligentSearch,
+ queryFullProfile,
+ queryPhoneByQQ,
+ UserValuationDoc
+} from "@/lib/mongodb"
+
+/**
+ * 脱敏手机号
+ */
+function maskPhone(phone: string | undefined): string {
+ if (!phone) return ''
+ if (phone.length !== 11) return phone
+ return `${phone.slice(0, 3)}****${phone.slice(-4)}`
+}
+
+/**
+ * 转换搜索结果
+ */
+function transformSearchResult(doc: UserValuationDoc, queryType: string): any {
+ const name = doc.name || '未知用户'
+ return {
+ id: doc._id?.toString(),
+ type: 'user',
+ title: name,
+ subtitle: doc.phone_masked || maskPhone(doc.phone),
+ description: `${doc.province || ''}${doc.city || ''} | ${doc.user_level || '未分级'} | RFM: ${doc.rfm_composite_score?.toFixed(2) || 'N/A'}`,
+ data: {
+ phone: doc.phone,
+ phone_masked: doc.phone_masked || maskPhone(doc.phone),
+ name: doc.name,
+ province: doc.province,
+ city: doc.city,
+ userLevel: doc.user_level,
+ rfmScore: doc.rfm_composite_score,
+ tags: doc.tags || [],
+ email: doc.email
+ },
+ matchedBy: queryType,
+ relevanceScore: doc.rfm_composite_score || 0
+ }
+}
+
+/**
+ * GET /api/search
+ * 智能搜索
+ */
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url)
const query = searchParams.get("q") || ""
- const type = (searchParams.get("type") as "user" | "traffic" | "all") || "all"
const limit = Number.parseInt(searchParams.get("limit") || "50")
const offset = Number.parseInt(searchParams.get("offset") || "0")
- const useAI = searchParams.get("ai") === "true"
- const includeInsights = searchParams.get("insights") === "true"
if (!query.trim()) {
- return NextResponse.json({ error: "搜索查询不能为空" }, { status: 400 })
+ return NextResponse.json({
+ error: "搜索查询不能为空"
+ }, { status: 400 })
}
- const searchService = getIntelligentSearchService()
+ // 执行智能搜索
+ const result = await intelligentSearch(query, { limit, offset })
+
+ // 转换结果
+ const items = result.users.map(doc => transformSearchResult(doc, result.queryType))
+
+ // 如果是 QQ 查询,补充 QQ 信息
+ if (result.queryType === 'qq' && result.users.length > 0) {
+ const qqInfo = await queryPhoneByQQ(query.trim())
+ if (qqInfo) {
+ items[0].data.qq = qqInfo.qq
+ items[0].data.qqScore = qqInfo.QQ号评分
+ items[0].data.carrier = qqInfo.运营商
+ }
+ }
- const results = await searchService.search(query, type, {
- limit,
- offset,
- useAI,
- includeInsights,
- filters: {},
+ return NextResponse.json({
+ query,
+ queryType: result.queryType,
+ total: result.total,
+ items,
+ pagination: {
+ limit,
+ offset,
+ hasMore: offset + items.length < result.total
+ }
})
-
- return NextResponse.json(results)
+
} catch (error) {
console.error("搜索API错误:", error)
- return NextResponse.json({ error: "搜索失败,请稍后重试" }, { status: 500 })
+ return NextResponse.json({
+ error: "搜索失败,请稍后重试",
+ details: error instanceof Error ? error.message : 'Unknown error'
+ }, { status: 500 })
}
}
+/**
+ * POST /api/search
+ * 高级搜索(支持更多参数)
+ * 返回格式适配前端 SearchResponse 接口
+ */
export async function POST(request: NextRequest) {
try {
+ const startTime = Date.now()
const body = await request.json()
const { query, type = "all", options = {} } = body
if (!query || !query.trim()) {
- return NextResponse.json({ error: "搜索查询不能为空" }, { status: 400 })
+ return NextResponse.json({
+ error: "搜索查询不能为空"
+ }, { status: 400 })
}
- const searchService = getIntelligentSearchService()
- const results = await searchService.search(query, type, options)
+ const limit = options.limit || 50
+ const offset = options.offset || 0
+
+ const result = await intelligentSearch(query, { limit, offset })
+
+ // 转换为前端期望的格式
+ const results = result.users.map(doc => {
+ const name = doc.name || '未知用户'
+ return {
+ id: doc._id?.toString() || '',
+ type: 'user' as const,
+ title: name,
+ description: `${doc.province || ''}${doc.city || ''} | 估值: ${doc.user_evaluation_score || 'N/A'}`,
+ tags: doc.tags || [],
+ relevanceScore: doc.user_evaluation_score || 0,
+ updatedAt: doc.computed_at?.toISOString() || new Date().toISOString(),
+ metadata: {
+ phone: doc.phone,
+ phone_masked: doc.phone_masked || maskPhone(doc.phone),
+ province: doc.province,
+ city: doc.city,
+ gender: doc.gender,
+ age_range: doc.age_range,
+ userLevel: doc.user_level,
+ rfmScore: doc.rfm_composite_score,
+ evaluationScore: doc.user_evaluation_score,
+ dataQuality: doc.data_quality
+ }
+ }
+ })
- return NextResponse.json(results)
+ const queryTime = Date.now() - startTime
+
+ // 返回前端期望的 SearchResponse 格式
+ return NextResponse.json({
+ results,
+ stats: {
+ totalResults: result.total,
+ queryTime,
+ suggestions: [],
+ filters: {
+ queryType: result.queryType
+ }
+ },
+ hasMore: offset + results.length < result.total
+ })
+
} catch (error) {
console.error("搜索API错误:", error)
- return NextResponse.json({ error: "搜索失败,请稍后重试" }, { status: 500 })
+ return NextResponse.json({
+ error: "搜索失败,请稍后重试",
+ results: [],
+ stats: { totalResults: 0, queryTime: 0, suggestions: [], filters: {} },
+ hasMore: false
+ }, { status: 500 })
}
}
diff --git a/app/api/system-status/route.ts b/app/api/system-status/route.ts
index 78963ee..57105c1 100644
--- a/app/api/system-status/route.ts
+++ b/app/api/system-status/route.ts
@@ -1,18 +1,78 @@
-import { type NextRequest, NextResponse } from "next/server"
-import { getMindsDBConnector } from "@/lib/mindsdb-connector"
+/**
+ * 系统状态 API 路由
+ * 返回 MongoDB 数据库真实状态
+ */
-export async function GET(request: NextRequest) {
+import { NextResponse } from "next/server"
+import { getDatabaseStats, healthCheck } from "@/lib/mongodb"
+
+/**
+ * GET /api/system-status
+ * 获取系统状态
+ */
+export async function GET() {
try {
- const mindsDB = getMindsDBConnector()
- const status = await mindsDB.getSystemStatus()
-
+ // 健康检查
+ const health = await healthCheck()
+
+ if (!health.mongodb) {
+ return NextResponse.json({
+ status: 'error',
+ connected: false,
+ latencyMs: health.latencyMs,
+ error: health.error || 'MongoDB 连接失败',
+ databases: [],
+ totalDocuments: 0,
+ totalSizeGB: 0,
+ lastCheck: new Date().toISOString()
+ }, { status: 503 })
+ }
+
+ // 获取数据库统计
+ const stats = await getDatabaseStats()
+
return NextResponse.json({
- success: true,
- status,
- timestamp: new Date().toISOString(),
+ status: 'healthy',
+ connected: stats.connected,
+ latencyMs: health.latencyMs,
+ databases: stats.databases,
+ totalDocuments: stats.totalDocuments,
+ totalSizeGB: stats.totalSizeGB,
+ lastCheck: new Date().toISOString(),
+ // 格式化显示
+ summary: {
+ userCount: formatNumber(stats.totalDocuments),
+ dataSize: `${stats.totalSizeGB} GB`,
+ dbCount: stats.databases.length,
+ responseTime: `${health.latencyMs}ms`
+ }
+ }, {
+ headers: { 'Cache-Control': 'no-store, max-age=0' }
})
+
} catch (error) {
- console.error("系统状态API错误:", error)
- return NextResponse.json({ error: "获取系统状态失败" }, { status: 500 })
+ console.error('System status error:', error)
+ return NextResponse.json({
+ status: 'error',
+ connected: false,
+ error: error instanceof Error ? error.message : 'Unknown error',
+ lastCheck: new Date().toISOString()
+ }, { status: 500 })
}
}
+
+/**
+ * 格式化数字显示
+ */
+function formatNumber(num: number): string {
+ if (num >= 1000000000) {
+ return `${(num / 1000000000).toFixed(2)}B`
+ }
+ if (num >= 1000000) {
+ return `${(num / 1000000).toFixed(1)}M`
+ }
+ if (num >= 1000) {
+ return `${(num / 1000).toFixed(1)}K`
+ }
+ return num.toString()
+}
diff --git a/app/api/users/route.ts b/app/api/users/route.ts
index fe85bff..e2d06da 100644
--- a/app/api/users/route.ts
+++ b/app/api/users/route.ts
@@ -1,230 +1,186 @@
+/**
+ * 用户 API 路由
+ * 对接神射手 MongoDB 数据库 - KR.用户估值
+ */
+
import { NextResponse, NextRequest } from "next/server"
-import type { TrafficUser } from "@/types/traffic"
-import { addUser, filterUsers, getDistinctTags, getUserById, queryUsers, type UserStatus } from "@/lib/mock-users"
+import {
+ queryUserList,
+ queryUserByPhone,
+ queryFullProfile,
+ UserValuationDoc
+} from "@/lib/mongodb"
-// 中文名字生成器数据
-const familyNames = [
- "张",
- "王",
- "李",
- "赵",
- "陈",
- "刘",
- "杨",
- "黄",
- "周",
- "吴",
- "朱",
- "孙",
- "马",
- "胡",
- "郭",
- "林",
- "何",
- "高",
- "梁",
- "郑",
- "罗",
- "宋",
- "谢",
- "唐",
- "韩",
- "曹",
- "许",
- "邓",
- "萧",
- "冯",
-]
-const givenNames1 = [
- "志",
- "建",
- "文",
- "明",
- "永",
- "春",
- "秀",
- "金",
- "水",
- "玉",
- "国",
- "立",
- "德",
- "海",
- "和",
- "荣",
- "伟",
- "新",
- "英",
- "佳",
-]
-const givenNames2 = [
- "华",
- "平",
- "军",
- "强",
- "辉",
- "敏",
- "峰",
- "磊",
- "超",
- "艳",
- "娜",
- "霞",
- "燕",
- "娟",
- "静",
- "丽",
- "涛",
- "洋",
- "勇",
- "龙",
-]
-
-// 生成固定的用户数据池
-const userPool: TrafficUser[] = Array.from({ length: 1610 }, (_, i) => {
- const familyName = familyNames[Math.floor(Math.random() * familyNames.length)]
- const givenName1 = givenNames1[Math.floor(Math.random() * givenNames1.length)]
- const givenName2 = givenNames2[Math.floor(Math.random() * givenNames2.length)]
- const fullName = Math.random() > 0.5 ? familyName + givenName1 + givenName2 : familyName + givenName1
-
- // 生成随机时间(在过去7天内)
- const date = new Date()
- date.setDate(date.getDate() - Math.floor(Math.random() * 7))
+/**
+ * 脱敏手机号
+ */
+function maskPhone(phone: string | undefined): string {
+ if (!phone) return ''
+ if (phone.length !== 11) return phone
+ return `${phone.slice(0, 3)}****${phone.slice(-4)}`
+}
+/**
+ * 转换用户数据格式(适配前端)
+ */
+function transformUser(doc: UserValuationDoc, index: number = 0): any {
+ const name = doc.name || '未知用户'
return {
- id: `${Date.now()}-${i}`,
- avatar: `/placeholder.svg?height=40&width=40&text=${fullName[0]}`,
- nickname: fullName,
- wechatId: `wxid_${Math.random().toString(36).substr(2, 8)}`,
- phone: `1${["3", "5", "7", "8", "9"][Math.floor(Math.random() * 5)]}${Array.from({ length: 9 }, () => Math.floor(Math.random() * 10)).join("")}`,
- region: [
- "广东深圳",
- "浙江杭州",
- "江苏苏州",
- "北京",
- "上海",
- "四川成都",
- "湖北武汉",
- "福建厦门",
- "山东青岛",
- "河南郑州",
- ][Math.floor(Math.random() * 10)],
- note: [
- "咨询产品价格",
- "对产品很感兴趣",
- "准备购买",
- "需要更多信息",
- "想了解优惠活动",
- "询问产品规格",
- "要求产品demo",
- "索要产品目录",
- "询问售后服务",
- "要求上门演示",
- ][Math.floor(Math.random() * 10)],
- status: ["pending", "added", "failed"][Math.floor(Math.random() * 3)] as TrafficUser["status"],
- addTime: date.toISOString(),
- source: ["抖音直播", "小红书", "微信朋友圈", "视频号", "公众号", "个人主页"][Math.floor(Math.random() * 6)],
- assignedTo: "",
- category: ["potential", "customer", "lost"][Math.floor(Math.random() * 3)] as TrafficUser["category"],
- tags: [],
+ id: doc._id?.toString() || `user-${index}`,
+ avatar: `/placeholder.svg?height=40&width=40&text=${name[0] || 'U'}`,
+ nickname: name,
+ wechatId: doc.phone ? `wxid_${doc.phone.slice(-8)}` : '',
+ phone: doc.phone || '',
+ phone_masked: doc.phone_masked || maskPhone(doc.phone),
+ region: doc.province && doc.city ? `${doc.province}${doc.city}` : (doc.province || '未知'),
+ note: '',
+ status: 'added' as const,
+ addTime: doc.created_at?.toISOString() || new Date().toISOString(),
+ source: (doc.source_channels && doc.source_channels[0]) || '神射手',
+ assignedTo: '',
+ category: 'customer' as const,
+ tags: doc.tags || [],
+ // RFM 数据
+ userLevel: doc.user_level || 'D',
+ rfmScore: doc.rfm_composite_score || 0,
+ rfmR: doc.rfm_r_score,
+ rfmF: doc.rfm_f_score,
+ rfmM: doc.rfm_m_score,
+ totalAmount: doc.total_amount || 0,
+ orderCount: doc.order_count || 0,
+ // 额外信息
+ email: doc.email,
+ address: doc.address,
+ province: doc.province,
+ city: doc.city,
}
-})
-
-// 计算今日新增数量
-const todayStart = new Date()
-todayStart.setHours(0, 0, 0, 0)
-const todayUsers = userPool.filter((user) => new Date(user.addTime) >= todayStart)
-
-// 生成微信好友数据池
-const generateWechatFriends = (wechatId: string, count: number) => {
- return Array.from({ length: count }, (_, i) => {
- const familyName = familyNames[Math.floor(Math.random() * familyNames.length)]
- const givenName1 = givenNames1[Math.floor(Math.random() * givenNames1.length)]
- const givenName2 = givenNames2[Math.floor(Math.random() * givenNames2.length)]
- const fullName = Math.random() > 0.5 ? familyName + givenName1 + givenName2 : familyName + givenName1
-
- // 生成随机时间(在过去30天内)
- const date = new Date()
- date.setDate(date.getDate() - Math.floor(Math.random() * 30))
-
- return {
- id: `wechat-${wechatId}-${i}`,
- avatar: `/placeholder.svg?height=40&width=40&text=${fullName[0]}`,
- nickname: fullName,
- wechatId: `wxid_${Math.random().toString(36).substr(2, 8)}`,
- phone: `1${["3", "5", "7", "8", "9"][Math.floor(Math.random() * 5)]}${Array.from({ length: 9 }, () => Math.floor(Math.random() * 10)).join("")}`,
- region: [
- "广东深圳",
- "浙江杭州",
- "江苏苏州",
- "北京",
- "上海",
- "四川成都",
- "湖北武汉",
- "福建厦门",
- "山东青岛",
- "河南郑州",
- ][Math.floor(Math.random() * 10)],
- note: [
- "咨询产品价格",
- "对产品很感兴趣",
- "准备购买",
- "需要更多信息",
- "想了解优惠活动",
- "询问产品规格",
- "要求产品demo",
- "索要产品目录",
- "询问售后服务",
- "要求上门演示",
- ][Math.floor(Math.random() * 10)],
- status: ["pending", "added", "failed"][Math.floor(Math.random() * 3)] as TrafficUser["status"],
- addTime: date.toISOString(),
- source: ["抖音直播", "小红书", "微信朋友圈", "视频号", "公众号", "个人主页", "微信好友"][
- Math.floor(Math.random() * 7)
- ],
- assignedTo: "",
- category: ["potential", "customer", "lost"][Math.floor(Math.random() * 3)] as TrafficUser["category"],
- tags: [],
- }
- })
-}
-
-// 微信好友数据缓存
-const wechatFriendsCache = new Map()
-
-function parseArrayParam(v: string | null) {
- if (!v) return []
- return v.split(",").map((s) => s.trim()).filter(Boolean)
}
+/**
+ * GET /api/users
+ * 查询用户列表或单个用户详情
+ */
export async function GET(req: NextRequest) {
- const { searchParams } = new URL(req.url)
-
- // 详情优先
- const id = searchParams.get('id')
- if (id) {
- const detail = getUserById(id)
- return NextResponse.json({ data: detail }, { headers: { 'Cache-Control': 'no-store' } })
+ try {
+ const { searchParams } = new URL(req.url)
+
+ // 单用户详情查询(按ID或手机号)
+ const id = searchParams.get('id')
+ const phone = searchParams.get('phone')
+
+ if (id || phone) {
+ // 如果是手机号格式,按手机号查询
+ const queryPhone = phone || (id && /^1[3-9]\d{9}$/.test(id) ? id : null)
+
+ if (queryPhone) {
+ // 完整画像查询(跨库)
+ const profile = await queryFullProfile(queryPhone)
+
+ if (profile.valuation) {
+ const user = transformUser(profile.valuation)
+
+ // 补充 QQ 信息
+ if (profile.qq) {
+ user.qq = profile.qq.qq
+ user.qqScore = profile.qq.QQ号评分
+ user.phoneScore = profile.qq.手机号评分
+ }
+
+ // 补充存客宝信息
+ if (profile.ckb) {
+ user.wechat = profile.ckb.social_accounts?.wechat
+ user.trafficPool = profile.ckb.traffic_pool?.pool_name
+ }
+
+ return NextResponse.json({
+ data: user,
+ sources: {
+ valuation: !!profile.valuation,
+ qq: !!profile.qq,
+ ckb: !!profile.ckb
+ }
+ }, { headers: { 'Cache-Control': 'no-store' } })
+ }
+
+ return NextResponse.json({
+ data: null,
+ error: '未找到该用户'
+ }, { status: 404 })
+ }
+
+ return NextResponse.json({
+ data: null,
+ error: '无效的查询参数'
+ }, { status: 400 })
+ }
+
+ // 列表查询
+ const q = searchParams.get('q') || undefined
+ const tagsStr = searchParams.get('tags') || ''
+ const userLevel = searchParams.get('userLevel') || searchParams.get('status') || undefined
+ const rfmMin = searchParams.get('rfmMin') ? Number(searchParams.get('rfmMin')) : undefined
+ const rfmMax = searchParams.get('rfmMax') ? Number(searchParams.get('rfmMax')) : undefined
+ const page = Number(searchParams.get('page') ?? 1)
+ const pageSize = Number(searchParams.get('pageSize') ?? 20)
+
+ const tags = tagsStr ? tagsStr.split(',').filter(Boolean) : undefined
+
+ const result = await queryUserList({
+ page,
+ pageSize,
+ userLevel,
+ minRfm: rfmMin,
+ maxRfm: rfmMax,
+ search: q,
+ tags
+ })
+
+ const transformedData = result.data.map((doc, i) => transformUser(doc, i))
+
+ return NextResponse.json({
+ data: transformedData,
+ total: result.total,
+ page,
+ pageSize,
+ totalPages: Math.ceil(result.total / pageSize)
+ }, { headers: { 'Cache-Control': 'no-store' } })
+
+ } catch (error) {
+ console.error('Users API error:', error)
+
+ // 数据库连接失败时返回模拟数据
+ return NextResponse.json({
+ data: [],
+ total: 0,
+ page: 1,
+ pageSize: 20,
+ totalPages: 0,
+ error: error instanceof Error ? error.message : '查询失败',
+ fallback: true
+ }, {
+ status: 500,
+ headers: { 'Cache-Control': 'no-store' }
+ })
}
-
- // 列表
- const q = searchParams.get('q') ?? undefined
- const tagsStr = searchParams.get('tags') ?? ''
- const statusStr = searchParams.get('status') ?? ''
- const rfmMin = Number(searchParams.get('rfmMin') ?? 0)
- const rfmMax = Number(searchParams.get('rfmMax') ?? 100)
- const page = Number(searchParams.get('page') ?? 1)
- const pageSize = Number(searchParams.get('pageSize') ?? 20)
-
- const tags = tagsStr ? tagsStr.split(',').filter(Boolean) : undefined
- const status = statusStr ? (statusStr.split(',').filter(Boolean) as any) : undefined
-
- const result = queryUsers({ q, tags, status, rfmMin, rfmMax, page, pageSize })
- return NextResponse.json(result, { headers: { 'Cache-Control': 'no-store' } })
}
+/**
+ * POST /api/users
+ * 创建用户(预留接口)
+ */
export async function POST(req: NextRequest) {
- const body = await req.json().catch(() => ({}))
- const created = addUser(body ?? {})
- return NextResponse.json({ data: created }, { status: 201 })
+ try {
+ const body = await req.json().catch(() => ({}))
+
+ // TODO: 实现用户创建逻辑
+ return NextResponse.json({
+ success: false,
+ error: '用户创建功能暂未开放'
+ }, { status: 501 })
+
+ } catch (error) {
+ return NextResponse.json({
+ error: error instanceof Error ? error.message : '创建失败'
+ }, { status: 500 })
+ }
}
diff --git a/app/login/page.tsx b/app/login/page.tsx
index 76c0403..9919308 100644
--- a/app/login/page.tsx
+++ b/app/login/page.tsx
@@ -2,7 +2,7 @@
import type React from "react"
import { useState, useEffect } from "react"
-import { Eye, EyeOff, Phone } from "lucide-react"
+import { Eye, EyeOff, Mail, Phone } from "lucide-react"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
@@ -12,8 +12,8 @@ import { WeChatIcon } from "@/components/icons/wechat-icon"
import { AppleIcon } from "@/components/icons/apple-icon"
import { useToast } from "@/components/ui/use-toast"
-// 使用环境变量获取API域名
-const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || "https://api.example.com"
+// 使用环境变量:不配置则用本地 API(支持 zhiqun@qq.com / Zhiqun1984)
+const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL ?? ""
// 定义登录响应类型
interface LoginResponse {
@@ -25,7 +25,7 @@ interface LoginResponse {
}
interface LoginForm {
- phone: string
+ account: string // 邮箱或手机号
password: string
verificationCode: string
agreeToTerms: boolean
@@ -36,7 +36,7 @@ export default function LoginPage() {
const [activeTab, setActiveTab] = useState<"password" | "verification">("password")
const [isLoading, setIsLoading] = useState(false)
const [form, setForm] = useState({
- phone: "",
+ account: "",
password: "",
verificationCode: "",
agreeToTerms: false,
@@ -55,11 +55,11 @@ export default function LoginPage() {
}
const validateForm = () => {
- if (!form.phone) {
+ if (!form.account.trim()) {
toast({
variant: "destructive",
- title: "请输入手机号",
- description: "手机号不能为空",
+ title: "请输入账号",
+ description: "请输入邮箱或手机号",
})
return false
}
@@ -101,9 +101,9 @@ export default function LoginPage() {
setIsLoading(true)
try {
- // 创建FormData对象
const formData = new FormData()
- formData.append("phone", form.phone)
+ const isEmail = form.account.includes("@")
+ formData.append(isEmail ? "email" : "phone", form.account)
if (activeTab === "password") {
formData.append("password", form.password)
@@ -111,8 +111,8 @@ export default function LoginPage() {
formData.append("verificationCode", form.verificationCode)
}
- // 发送登录请求
- const response = await fetch(`${API_BASE_URL}/auth/login`, {
+ const apiUrl = API_BASE_URL ? `${API_BASE_URL}/auth/login` : "/api/auth/login"
+ const response = await fetch(apiUrl, {
method: "POST",
body: formData,
// 不需要设置Content-Type,浏览器会自动设置为multipart/form-data并添加boundary
@@ -125,7 +125,7 @@ export default function LoginPage() {
localStorage.setItem("token", result.data.token)
// 成功后跳转
- router.push("/profile")
+ router.push("/")
toast({
title: "登录成功",
@@ -146,7 +146,7 @@ export default function LoginPage() {
}
const handleSendVerificationCode = async () => {
- if (!form.phone) {
+ if (!form.account.trim()) {
toast({
variant: "destructive",
title: "请输入手机号",
@@ -157,12 +157,11 @@ export default function LoginPage() {
setIsLoading(true)
try {
- // 创建FormData对象
const formData = new FormData()
- formData.append("phone", form.phone)
+ formData.append("phone", form.account)
- // 发送验证码请求
- const response = await fetch(`${API_BASE_URL}/auth/send-code`, {
+ const apiUrl = API_BASE_URL ? `${API_BASE_URL}/auth/send-code` : "/api/auth/send-code"
+ const response = await fetch(apiUrl, {
method: "POST",
body: formData,
})
@@ -220,22 +219,25 @@ export default function LoginPage() {
-
你所在地区仅支持 手机号 / 微信 / Apple 登录
+
支持 邮箱 / 手机号 / 微信 / Apple 登录