chore: 以本地为准,强制同步全部内容
43
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)
|
||||
|
||||
BIN
app/.DS_Store
vendored
Normal file
@@ -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 (
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<title>神射手 - 登录</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
</head>
|
||||
<body className={inter.className}>
|
||||
{children}
|
||||
<Toaster />
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
@@ -65,6 +84,8 @@ export default function ClientLayout({
|
||||
|
||||
{/* 移动端底部导航 */}
|
||||
{isMobile && <BottomNav />}
|
||||
|
||||
<Toaster />
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
|
||||
77
app/api/auth/login/route.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
|
||||
/**
|
||||
* 本地登录 API - 支持邮箱/手机号 + 密码
|
||||
* 当未配置 NEXT_PUBLIC_API_BASE_URL 时使用
|
||||
* 开发账号: zhiqun@qq.com / Zhiqun1984
|
||||
*/
|
||||
const MOCK_USERS: Record<string, { password: string }> = {
|
||||
"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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
31
app/api/auth/send-code/route.ts
Normal file
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 results = await searchService.search(query, type, {
|
||||
// 转换结果
|
||||
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.运营商
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
query,
|
||||
queryType: result.queryType,
|
||||
total: result.total,
|
||||
items,
|
||||
pagination: {
|
||||
limit,
|
||||
offset,
|
||||
useAI,
|
||||
includeInsights,
|
||||
filters: {},
|
||||
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
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
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
|
||||
})
|
||||
|
||||
return NextResponse.json(results)
|
||||
} 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 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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<string, TrafficUser[]>()
|
||||
|
||||
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) {
|
||||
try {
|
||||
const { searchParams } = new URL(req.url)
|
||||
|
||||
// 详情优先
|
||||
// 单用户详情查询(按ID或手机号)
|
||||
const id = searchParams.get('id')
|
||||
if (id) {
|
||||
const detail = getUserById(id)
|
||||
return NextResponse.json({ data: detail }, { headers: { 'Cache-Control': 'no-store' } })
|
||||
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.手机号评分
|
||||
}
|
||||
|
||||
// 列表
|
||||
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)
|
||||
// 补充存客宝信息
|
||||
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 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' } })
|
||||
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' }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/users
|
||||
* 创建用户(预留接口)
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json().catch(() => ({}))
|
||||
const created = addUser(body ?? {})
|
||||
return NextResponse.json({ data: created }, { status: 201 })
|
||||
|
||||
// TODO: 实现用户创建逻辑
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: '用户创建功能暂未开放'
|
||||
}, { status: 501 })
|
||||
|
||||
} catch (error) {
|
||||
return NextResponse.json({
|
||||
error: error instanceof Error ? error.message : '创建失败'
|
||||
}, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<LoginForm>({
|
||||
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() {
|
||||
</TabsList>
|
||||
|
||||
<div className="mt-8">
|
||||
<p className="text-gray-600 mb-6">你所在地区仅支持 手机号 / 微信 / Apple 登录</p>
|
||||
<p className="text-gray-600 mb-6">支持 邮箱 / 手机号 / 微信 / Apple 登录</p>
|
||||
|
||||
<form onSubmit={handleLogin} className="space-y-6">
|
||||
<div className="relative">
|
||||
<Input
|
||||
type="tel"
|
||||
name="phone"
|
||||
value={form.phone}
|
||||
type="text"
|
||||
name="account"
|
||||
value={form.account}
|
||||
onChange={handleInputChange}
|
||||
placeholder="手机号"
|
||||
className="pl-16 border-gray-300 text-gray-900 h-12"
|
||||
placeholder="邮箱或手机号"
|
||||
className="pl-12 border-gray-300 text-gray-900 h-12"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<span className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-500 flex items-center gap-1">
|
||||
<span className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-500">
|
||||
{form.account.includes("@") ? (
|
||||
<Mail className="h-4 w-4" />
|
||||
) : (
|
||||
<Phone className="h-4 w-4" />
|
||||
+86
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
BIN
components/.DS_Store
vendored
Normal file
BIN
lib/.DS_Store
vendored
Normal file
515
lib/mongodb.ts
Normal file
@@ -0,0 +1,515 @@
|
||||
/**
|
||||
* 神射手 MongoDB 连接器
|
||||
* 连接到卡若AI神射手后端数据库
|
||||
*
|
||||
* 数据库概览:
|
||||
* - KR.用户估值: 1436万条 - 统一画像、RFM评分
|
||||
* - KR_腾讯.QQ+手机: 7.05亿条 - QQ↔手机关联
|
||||
* - KR_微博.微博uid+手机: 2.17亿条 - 微博UID↔手机
|
||||
* - KR_京东.jd_com: 1.42亿条 - 京东用户
|
||||
* - KR_存客宝.用户资产统一视图: 21.6万条 - 存客宝用户
|
||||
*/
|
||||
|
||||
import { MongoClient, Db, Collection, Document } from 'mongodb'
|
||||
|
||||
// MongoDB 连接配置
|
||||
const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://admin:admin123@localhost:27017/?authSource=admin'
|
||||
|
||||
// 数据库名称常量
|
||||
export const DB_NAMES = {
|
||||
KR: 'KR',
|
||||
KR_TENCENT: 'KR_腾讯',
|
||||
KR_WEIBO: 'KR_微博',
|
||||
KR_JD: 'KR_京东',
|
||||
KR_HOTEL: 'KR_酒店',
|
||||
KR_SF: 'KR_顺丰',
|
||||
KR_CKB: 'KR_存客宝',
|
||||
} as const
|
||||
|
||||
// 集合名称常量
|
||||
export const COLLECTION_NAMES = {
|
||||
USER_VALUATION: '用户估值',
|
||||
QQ_PHONE: 'QQ+手机',
|
||||
WEIBO_UID_PHONE: '微博uid+手机',
|
||||
JD_COM: 'jd_com',
|
||||
HOTEL_RECORDS: '酒店开房记录_2013年8月_2000万内',
|
||||
SF_EXPRESS: '顺丰快递数据',
|
||||
CKB_UNIFIED: '用户资产统一视图',
|
||||
} as const
|
||||
|
||||
// 全局客户端实例(连接池复用)
|
||||
let client: MongoClient | null = null
|
||||
let clientPromise: Promise<MongoClient> | null = null
|
||||
|
||||
/**
|
||||
* 获取 MongoDB 客户端(单例模式)
|
||||
*/
|
||||
export async function getMongoClient(): Promise<MongoClient> {
|
||||
if (client) {
|
||||
return client
|
||||
}
|
||||
|
||||
if (!clientPromise) {
|
||||
clientPromise = MongoClient.connect(MONGODB_URI, {
|
||||
maxPoolSize: 10,
|
||||
minPoolSize: 2,
|
||||
maxIdleTimeMS: 60000,
|
||||
serverSelectionTimeoutMS: 5000,
|
||||
connectTimeoutMS: 10000,
|
||||
})
|
||||
}
|
||||
|
||||
client = await clientPromise
|
||||
return client
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定数据库
|
||||
*/
|
||||
export async function getDatabase(dbName: string): Promise<Db> {
|
||||
const client = await getMongoClient()
|
||||
return client.db(dbName)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定集合
|
||||
*/
|
||||
export async function getCollection<T extends Document = Document>(
|
||||
dbName: string,
|
||||
collectionName: string
|
||||
): Promise<Collection<T>> {
|
||||
const db = await getDatabase(dbName)
|
||||
return db.collection<T>(collectionName)
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户估值文档类型(匹配实际数据库结构)
|
||||
*/
|
||||
export interface UserValuationDoc {
|
||||
_id?: any
|
||||
user_key?: string
|
||||
phone?: string
|
||||
phone_masked?: string
|
||||
name?: string
|
||||
email?: string
|
||||
email_masked?: string
|
||||
id_number_masked?: string
|
||||
gender?: string
|
||||
age_range?: string
|
||||
province?: string
|
||||
city?: string
|
||||
address?: string
|
||||
// 评分字段
|
||||
user_evaluation_score?: number // 用户估值分
|
||||
rfm_composite_score?: number
|
||||
rfm_r_score?: number
|
||||
rfm_f_score?: number
|
||||
rfm_m_score?: number
|
||||
user_level?: string
|
||||
// 来源和标签
|
||||
source_collections?: string[]
|
||||
source_channels?: string[]
|
||||
first_channel?: string
|
||||
merge_evidence?: string[]
|
||||
tags?: string[]
|
||||
// 数据质量
|
||||
data_quality?: {
|
||||
completeness?: number
|
||||
source_count?: number
|
||||
}
|
||||
// 时间
|
||||
computed_at?: Date
|
||||
created_at?: Date
|
||||
updated_at?: Date
|
||||
version?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* QQ+手机文档类型
|
||||
*/
|
||||
export interface QQPhoneDoc {
|
||||
_id?: any
|
||||
qq?: string
|
||||
phone?: string
|
||||
省份?: string
|
||||
地区?: string
|
||||
运营商?: string
|
||||
手机号评分?: number
|
||||
QQ号评分?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 存客宝用户资产文档类型
|
||||
*/
|
||||
export interface CKBUserAssetDoc {
|
||||
_id?: any
|
||||
user_key?: string
|
||||
phone?: string
|
||||
phone_masked?: string
|
||||
core_profile?: {
|
||||
name?: string
|
||||
province?: string
|
||||
city?: string
|
||||
address?: string
|
||||
email?: string
|
||||
}
|
||||
social_accounts?: {
|
||||
wechat?: string
|
||||
qq?: string
|
||||
}
|
||||
rfm_scores?: {
|
||||
composite_score?: number
|
||||
user_level?: string
|
||||
R?: number
|
||||
F?: number
|
||||
M?: number
|
||||
}
|
||||
user_evaluation_score?: number
|
||||
unified_tags?: string[]
|
||||
traffic_pool?: {
|
||||
pool_id?: string
|
||||
pool_name?: string
|
||||
}
|
||||
source_channels?: string[]
|
||||
}
|
||||
|
||||
// ========== 快捷查询函数 ==========
|
||||
|
||||
/**
|
||||
* 标准化手机号格式
|
||||
* 支持: 13800138000, +8613800138000, 8613800138000
|
||||
*/
|
||||
function normalizePhone(phone: string): string[] {
|
||||
const cleaned = phone.replace(/\D/g, '')
|
||||
const variants: string[] = []
|
||||
|
||||
if (cleaned.startsWith('86') && cleaned.length === 13) {
|
||||
// 8613800138000 -> 多种格式
|
||||
const base = cleaned.slice(2)
|
||||
variants.push(base, `+86${base}`, `86${base}`, cleaned)
|
||||
} else if (cleaned.length === 11 && cleaned.startsWith('1')) {
|
||||
// 13800138000 -> 多种格式
|
||||
variants.push(cleaned, `+86${cleaned}`, `86${cleaned}`)
|
||||
} else {
|
||||
variants.push(phone, cleaned)
|
||||
}
|
||||
|
||||
return [...new Set(variants)]
|
||||
}
|
||||
|
||||
/**
|
||||
* 按手机号查询用户估值
|
||||
*/
|
||||
export async function queryUserByPhone(phone: string): Promise<UserValuationDoc | null> {
|
||||
const coll = await getCollection<UserValuationDoc>(DB_NAMES.KR, COLLECTION_NAMES.USER_VALUATION)
|
||||
|
||||
// 尝试多种手机号格式
|
||||
const phoneVariants = normalizePhone(phone)
|
||||
|
||||
return coll.findOne({
|
||||
$or: phoneVariants.map(p => ({ phone: p }))
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 按手机号查询QQ
|
||||
*/
|
||||
export async function queryQQByPhone(phone: string): Promise<QQPhoneDoc | null> {
|
||||
const coll = await getCollection<QQPhoneDoc>(DB_NAMES.KR_TENCENT, COLLECTION_NAMES.QQ_PHONE)
|
||||
return coll.findOne({ phone })
|
||||
}
|
||||
|
||||
/**
|
||||
* 按QQ号查询手机
|
||||
*/
|
||||
export async function queryPhoneByQQ(qq: string): Promise<QQPhoneDoc | null> {
|
||||
const coll = await getCollection<QQPhoneDoc>(DB_NAMES.KR_TENCENT, COLLECTION_NAMES.QQ_PHONE)
|
||||
return coll.findOne({ qq })
|
||||
}
|
||||
|
||||
/**
|
||||
* 跨库完整画像查询(并行)
|
||||
*/
|
||||
export async function queryFullProfile(phone: string): Promise<{
|
||||
valuation: UserValuationDoc | null
|
||||
qq: QQPhoneDoc | null
|
||||
ckb: CKBUserAssetDoc | null
|
||||
}> {
|
||||
const [valuation, qq, ckb] = await Promise.all([
|
||||
queryUserByPhone(phone),
|
||||
queryQQByPhone(phone),
|
||||
(async () => {
|
||||
const coll = await getCollection<CKBUserAssetDoc>(DB_NAMES.KR_CKB, COLLECTION_NAMES.CKB_UNIFIED)
|
||||
return coll.findOne({ phone })
|
||||
})()
|
||||
])
|
||||
|
||||
return { valuation, qq, ckb }
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户列表查询(分页)
|
||||
*/
|
||||
export async function queryUserList(options: {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
userLevel?: string
|
||||
minRfm?: number
|
||||
maxRfm?: number
|
||||
search?: string
|
||||
tags?: string[]
|
||||
}): Promise<{ data: UserValuationDoc[], total: number }> {
|
||||
const {
|
||||
page = 1,
|
||||
pageSize = 20,
|
||||
userLevel,
|
||||
minRfm,
|
||||
maxRfm,
|
||||
search,
|
||||
tags
|
||||
} = options
|
||||
|
||||
const coll = await getCollection<UserValuationDoc>(DB_NAMES.KR, COLLECTION_NAMES.USER_VALUATION)
|
||||
|
||||
// 构建查询条件
|
||||
const query: any = {}
|
||||
|
||||
if (userLevel) {
|
||||
query.user_level = userLevel
|
||||
}
|
||||
|
||||
if (minRfm !== undefined || maxRfm !== undefined) {
|
||||
query.rfm_composite_score = {}
|
||||
if (minRfm !== undefined) query.rfm_composite_score.$gte = minRfm
|
||||
if (maxRfm !== undefined) query.rfm_composite_score.$lte = maxRfm
|
||||
}
|
||||
|
||||
if (search) {
|
||||
query.$or = [
|
||||
{ phone: { $regex: search, $options: 'i' } },
|
||||
{ name: { $regex: search, $options: 'i' } },
|
||||
{ phone_masked: { $regex: search, $options: 'i' } }
|
||||
]
|
||||
}
|
||||
|
||||
if (tags && tags.length > 0) {
|
||||
query.tags = { $in: tags }
|
||||
}
|
||||
|
||||
const [data, total] = await Promise.all([
|
||||
coll.find(query)
|
||||
.skip((page - 1) * pageSize)
|
||||
.limit(pageSize)
|
||||
.sort({ rfm_composite_score: -1 })
|
||||
.toArray(),
|
||||
coll.countDocuments(query)
|
||||
])
|
||||
|
||||
return { data, total }
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取数据库统计信息
|
||||
*/
|
||||
export async function getDatabaseStats(): Promise<{
|
||||
connected: boolean
|
||||
databases: { name: string, collections: number, documents: number, sizeGB: number }[]
|
||||
totalDocuments: number
|
||||
totalSizeGB: number
|
||||
}> {
|
||||
try {
|
||||
const client = await getMongoClient()
|
||||
const adminDb = client.db('admin')
|
||||
|
||||
// 获取数据库列表
|
||||
const dbs = await adminDb.admin().listDatabases()
|
||||
|
||||
const databases = []
|
||||
let totalDocuments = 0
|
||||
let totalSizeGB = 0
|
||||
|
||||
for (const db of dbs.databases) {
|
||||
if (db.name.startsWith('KR')) {
|
||||
const database = client.db(db.name)
|
||||
const collections = await database.listCollections().toArray()
|
||||
|
||||
let dbDocCount = 0
|
||||
for (const coll of collections) {
|
||||
try {
|
||||
const count = await database.collection(coll.name).estimatedDocumentCount()
|
||||
dbDocCount += count
|
||||
} catch (e) {
|
||||
// 忽略错误
|
||||
}
|
||||
}
|
||||
|
||||
const sizeGB = (db.sizeOnDisk || 0) / (1024 * 1024 * 1024)
|
||||
|
||||
databases.push({
|
||||
name: db.name,
|
||||
collections: collections.length,
|
||||
documents: dbDocCount,
|
||||
sizeGB: Math.round(sizeGB * 100) / 100
|
||||
})
|
||||
|
||||
totalDocuments += dbDocCount
|
||||
totalSizeGB += sizeGB
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
connected: true,
|
||||
databases,
|
||||
totalDocuments,
|
||||
totalSizeGB: Math.round(totalSizeGB * 100) / 100
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('MongoDB stats error:', error)
|
||||
return {
|
||||
connected: false,
|
||||
databases: [],
|
||||
totalDocuments: 0,
|
||||
totalSizeGB: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* RFM 用户分组统计
|
||||
*/
|
||||
export async function getRFMGroupSummary(): Promise<{
|
||||
levels: { level: string, count: number, percentage: number }[]
|
||||
total: number
|
||||
}> {
|
||||
const coll = await getCollection<UserValuationDoc>(DB_NAMES.KR, COLLECTION_NAMES.USER_VALUATION)
|
||||
|
||||
const pipeline = [
|
||||
{
|
||||
$group: {
|
||||
_id: '$user_level',
|
||||
count: { $sum: 1 }
|
||||
}
|
||||
},
|
||||
{
|
||||
$sort: { count: -1 }
|
||||
}
|
||||
]
|
||||
|
||||
const result = await coll.aggregate(pipeline).toArray()
|
||||
const total = result.reduce((sum, item) => sum + item.count, 0)
|
||||
|
||||
const levels = result.map(item => ({
|
||||
level: item._id || '未分类',
|
||||
count: item.count,
|
||||
percentage: Math.round((item.count / total) * 10000) / 100
|
||||
}))
|
||||
|
||||
return { levels, total }
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有标签
|
||||
*/
|
||||
export async function getDistinctTags(): Promise<string[]> {
|
||||
const coll = await getCollection<UserValuationDoc>(DB_NAMES.KR, COLLECTION_NAMES.USER_VALUATION)
|
||||
const tags = await coll.distinct('tags')
|
||||
return tags.filter(Boolean) as string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 智能搜索 - 跨库查询
|
||||
*/
|
||||
export async function intelligentSearch(query: string, options: {
|
||||
limit?: number
|
||||
offset?: number
|
||||
}): Promise<{
|
||||
users: UserValuationDoc[]
|
||||
total: number
|
||||
queryType: 'phone' | 'name' | 'qq' | 'email' | 'general'
|
||||
}> {
|
||||
const { limit = 20, offset = 0 } = options
|
||||
|
||||
// 识别查询类型
|
||||
let queryType: 'phone' | 'name' | 'qq' | 'email' | 'general' = 'general'
|
||||
const trimmed = query.trim()
|
||||
|
||||
if (/^1[3-9]\d{9}$/.test(trimmed)) {
|
||||
queryType = 'phone'
|
||||
} else if (/^\d{5,12}$/.test(trimmed)) {
|
||||
queryType = 'qq'
|
||||
} else if (/@/.test(trimmed)) {
|
||||
queryType = 'email'
|
||||
} else if (/^[\u4e00-\u9fa5]{2,4}$/.test(trimmed)) {
|
||||
queryType = 'name'
|
||||
}
|
||||
|
||||
const coll = await getCollection<UserValuationDoc>(DB_NAMES.KR, COLLECTION_NAMES.USER_VALUATION)
|
||||
|
||||
let filter: any = {}
|
||||
|
||||
switch (queryType) {
|
||||
case 'phone':
|
||||
// 使用多种格式匹配
|
||||
const phoneVariants = normalizePhone(trimmed)
|
||||
filter = { $or: phoneVariants.map(p => ({ phone: p })) }
|
||||
break
|
||||
case 'qq':
|
||||
// 先查QQ找手机,再查用户估值
|
||||
const qqResult = await queryPhoneByQQ(trimmed)
|
||||
if (qqResult?.phone) {
|
||||
const qqPhoneVariants = normalizePhone(qqResult.phone)
|
||||
filter = { $or: qqPhoneVariants.map(p => ({ phone: p })) }
|
||||
} else {
|
||||
filter = { _id: null } // 查不到
|
||||
}
|
||||
break
|
||||
case 'email':
|
||||
filter = { email: { $regex: trimmed, $options: 'i' } }
|
||||
break
|
||||
case 'name':
|
||||
filter = { name: trimmed }
|
||||
break
|
||||
default:
|
||||
filter = {
|
||||
$or: [
|
||||
{ phone: { $regex: trimmed, $options: 'i' } },
|
||||
{ name: { $regex: trimmed, $options: 'i' } },
|
||||
{ phone_masked: { $regex: trimmed, $options: 'i' } }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
const [users, total] = await Promise.all([
|
||||
coll.find(filter).skip(offset).limit(limit).toArray(),
|
||||
coll.countDocuments(filter)
|
||||
])
|
||||
|
||||
return { users, total, queryType }
|
||||
}
|
||||
|
||||
/**
|
||||
* 健康检查
|
||||
*/
|
||||
export async function healthCheck(): Promise<{
|
||||
mongodb: boolean
|
||||
latencyMs: number
|
||||
error?: string
|
||||
}> {
|
||||
const start = Date.now()
|
||||
|
||||
try {
|
||||
const client = await getMongoClient()
|
||||
await client.db('admin').command({ ping: 1 })
|
||||
|
||||
return {
|
||||
mongodb: true,
|
||||
latencyMs: Date.now() - start
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
mongodb: false,
|
||||
latencyMs: Date.now() - start,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
"start": "next start"
|
||||
},
|
||||
"dependencies": {
|
||||
"mongodb": "^6.3.0",
|
||||
"@ai-sdk/openai": "latest",
|
||||
"@ant-design/plots": "latest",
|
||||
"@radix-ui/react-accordion": "latest",
|
||||
|
||||
122
pnpm-lock.yaml
generated
@@ -110,6 +110,9 @@ importers:
|
||||
lucide-react:
|
||||
specifier: ^0.454.0
|
||||
version: 0.454.0(react@18.0.0)
|
||||
mongodb:
|
||||
specifier: ^6.3.0
|
||||
version: 6.21.0
|
||||
next:
|
||||
specifier: 14.2.16
|
||||
version: 14.2.16(@opentelemetry/api@1.9.0)(react-dom@18.0.0(react@18.0.0))(react@18.0.0)
|
||||
@@ -345,6 +348,9 @@ packages:
|
||||
'@jridgewell/trace-mapping@0.3.29':
|
||||
resolution: {integrity: sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==}
|
||||
|
||||
'@mongodb-js/saslprep@1.4.5':
|
||||
resolution: {integrity: sha512-k64Lbyb7ycCSXHSLzxVdb2xsKGPMvYZfCICXvDsI8Z65CeWQzTEKS4YmGbnqw+U9RBvLPTsB6UCmwkgsDTGWIw==}
|
||||
|
||||
'@napi-rs/wasm-runtime@0.2.12':
|
||||
resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==}
|
||||
|
||||
@@ -371,24 +377,28 @@ packages:
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@next/swc-linux-arm64-musl@14.2.16':
|
||||
resolution: {integrity: sha512-X2YSyu5RMys8R2lA0yLMCOCtqFOoLxrq2YbazFvcPOE4i/isubYjkh+JCpRmqYfEuCVltvlo+oGfj/b5T2pKUA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@next/swc-linux-x64-gnu@14.2.16':
|
||||
resolution: {integrity: sha512-9AGcX7VAkGbc5zTSa+bjQ757tkjr6C/pKS7OK8cX7QEiK6MHIIezBLcQ7gQqbDW2k5yaqba2aDtaBeyyZh1i6Q==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@next/swc-linux-x64-musl@14.2.16':
|
||||
resolution: {integrity: sha512-Klgeagrdun4WWDaOizdbtIIm8khUDQJ/5cRzdpXHfkbY91LxBXeejL4kbZBrpR/nmgRrQvmz4l3OtttNVkz2Sg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@next/swc-win32-arm64-msvc@14.2.16':
|
||||
resolution: {integrity: sha512-PwW8A1UC1Y0xIm83G3yFGPiOBftJK4zukTmk7DI1CebyMOoaVpd8aSy7K6GhobzhkjYvqS/QmzcfsWG2Dwizdg==}
|
||||
@@ -1094,6 +1104,12 @@ packages:
|
||||
'@types/use-sync-external-store@0.0.6':
|
||||
resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==}
|
||||
|
||||
'@types/webidl-conversions@7.0.3':
|
||||
resolution: {integrity: sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==}
|
||||
|
||||
'@types/whatwg-url@11.0.5':
|
||||
resolution: {integrity: sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ==}
|
||||
|
||||
'@typescript-eslint/parser@6.21.0':
|
||||
resolution: {integrity: sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==}
|
||||
engines: {node: ^16.0.0 || >=18.0.0}
|
||||
@@ -1164,41 +1180,49 @@ packages:
|
||||
resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@unrs/resolver-binding-linux-arm64-musl@1.11.1':
|
||||
resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@unrs/resolver-binding-linux-ppc64-gnu@1.11.1':
|
||||
resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@unrs/resolver-binding-linux-riscv64-gnu@1.11.1':
|
||||
resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@unrs/resolver-binding-linux-riscv64-musl@1.11.1':
|
||||
resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@unrs/resolver-binding-linux-s390x-gnu@1.11.1':
|
||||
resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@unrs/resolver-binding-linux-x64-gnu@1.11.1':
|
||||
resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@unrs/resolver-binding-linux-x64-musl@1.11.1':
|
||||
resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@unrs/resolver-binding-wasm32-wasi@1.11.1':
|
||||
resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==}
|
||||
@@ -1364,6 +1388,10 @@ packages:
|
||||
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
|
||||
hasBin: true
|
||||
|
||||
bson@6.10.4:
|
||||
resolution: {integrity: sha512-WIsKqkSC0ABoBJuT1LEX+2HEvNmNKKgnTAyd0fL8qzK4SH2i9NXg+t08YtdZp/V9IZ33cxe3iV4yM0qg8lMQng==}
|
||||
engines: {node: '>=16.20.1'}
|
||||
|
||||
busboy@1.6.0:
|
||||
resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==}
|
||||
engines: {node: '>=10.16.0'}
|
||||
@@ -2250,6 +2278,9 @@ packages:
|
||||
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
memory-pager@1.5.0:
|
||||
resolution: {integrity: sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==}
|
||||
|
||||
merge2@1.4.1:
|
||||
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
|
||||
engines: {node: '>= 8'}
|
||||
@@ -2279,6 +2310,36 @@ packages:
|
||||
resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==}
|
||||
engines: {node: '>=16 || 14 >=14.17'}
|
||||
|
||||
mongodb-connection-string-url@3.0.2:
|
||||
resolution: {integrity: sha512-rMO7CGo/9BFwyZABcKAWL8UJwH/Kc2x0g72uhDWzG48URRax5TCIcJ7Rc3RZqffZzO/Gwff/jyKwCU9TN8gehA==}
|
||||
|
||||
mongodb@6.21.0:
|
||||
resolution: {integrity: sha512-URyb/VXMjJ4da46OeSXg+puO39XH9DeQpWCslifrRn9JWugy0D+DvvBvkm2WxmHe61O/H19JM66p1z7RHVkZ6A==}
|
||||
engines: {node: '>=16.20.1'}
|
||||
peerDependencies:
|
||||
'@aws-sdk/credential-providers': ^3.188.0
|
||||
'@mongodb-js/zstd': ^1.1.0 || ^2.0.0
|
||||
gcp-metadata: ^5.2.0
|
||||
kerberos: ^2.0.1
|
||||
mongodb-client-encryption: '>=6.0.0 <7'
|
||||
snappy: ^7.3.2
|
||||
socks: ^2.7.1
|
||||
peerDependenciesMeta:
|
||||
'@aws-sdk/credential-providers':
|
||||
optional: true
|
||||
'@mongodb-js/zstd':
|
||||
optional: true
|
||||
gcp-metadata:
|
||||
optional: true
|
||||
kerberos:
|
||||
optional: true
|
||||
mongodb-client-encryption:
|
||||
optional: true
|
||||
snappy:
|
||||
optional: true
|
||||
socks:
|
||||
optional: true
|
||||
|
||||
ms@2.1.3:
|
||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||
|
||||
@@ -2746,6 +2807,9 @@ packages:
|
||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
sparse-bitfield@3.0.3:
|
||||
resolution: {integrity: sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==}
|
||||
|
||||
stable-hash@0.0.5:
|
||||
resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==}
|
||||
|
||||
@@ -2870,6 +2934,10 @@ packages:
|
||||
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
|
||||
engines: {node: '>=8.0'}
|
||||
|
||||
tr46@5.1.1:
|
||||
resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
ts-api-utils@1.4.3:
|
||||
resolution: {integrity: sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==}
|
||||
engines: {node: '>=16'}
|
||||
@@ -2970,6 +3038,14 @@ packages:
|
||||
victory-vendor@37.3.6:
|
||||
resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==}
|
||||
|
||||
webidl-conversions@7.0.0:
|
||||
resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
whatwg-url@14.2.0:
|
||||
resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
which-boxed-primitive@1.1.1:
|
||||
resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -3366,6 +3442,10 @@ snapshots:
|
||||
'@jridgewell/resolve-uri': 3.1.2
|
||||
'@jridgewell/sourcemap-codec': 1.5.4
|
||||
|
||||
'@mongodb-js/saslprep@1.4.5':
|
||||
dependencies:
|
||||
sparse-bitfield: 3.0.3
|
||||
|
||||
'@napi-rs/wasm-runtime@0.2.12':
|
||||
dependencies:
|
||||
'@emnapi/core': 1.4.5
|
||||
@@ -4106,6 +4186,12 @@ snapshots:
|
||||
|
||||
'@types/use-sync-external-store@0.0.6': {}
|
||||
|
||||
'@types/webidl-conversions@7.0.3': {}
|
||||
|
||||
'@types/whatwg-url@11.0.5':
|
||||
dependencies:
|
||||
'@types/webidl-conversions': 7.0.3
|
||||
|
||||
'@typescript-eslint/parser@6.21.0(eslint@8.0.0)(typescript@5.0.2)':
|
||||
dependencies:
|
||||
'@typescript-eslint/scope-manager': 6.21.0
|
||||
@@ -4370,6 +4456,8 @@ snapshots:
|
||||
node-releases: 2.0.19
|
||||
update-browserslist-db: 1.1.3(browserslist@4.25.1)
|
||||
|
||||
bson@6.10.4: {}
|
||||
|
||||
busboy@1.6.0:
|
||||
dependencies:
|
||||
streamsearch: 1.1.0
|
||||
@@ -4769,7 +4857,7 @@ snapshots:
|
||||
eslint: 8.0.0
|
||||
eslint-import-resolver-node: 0.3.9
|
||||
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(eslint@8.0.0))(eslint@8.0.0)
|
||||
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@6.21.0(eslint@8.0.0)(typescript@5.0.2))(eslint-import-resolver-typescript@3.10.1)(eslint@8.0.0)
|
||||
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@6.21.0(eslint@8.0.0)(typescript@5.0.2))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(eslint@8.0.0))(eslint@8.0.0))(eslint@8.0.0)
|
||||
eslint-plugin-jsx-a11y: 6.10.2(eslint@8.0.0)
|
||||
eslint-plugin-react: 7.37.5(eslint@8.0.0)
|
||||
eslint-plugin-react-hooks: 5.0.0-canary-7118f5dd7-20230705(eslint@8.0.0)
|
||||
@@ -4799,7 +4887,7 @@ snapshots:
|
||||
tinyglobby: 0.2.14
|
||||
unrs-resolver: 1.11.1
|
||||
optionalDependencies:
|
||||
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@6.21.0(eslint@8.0.0)(typescript@5.0.2))(eslint-import-resolver-typescript@3.10.1)(eslint@8.0.0)
|
||||
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@6.21.0(eslint@8.0.0)(typescript@5.0.2))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(eslint@8.0.0))(eslint@8.0.0))(eslint@8.0.0)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -4814,7 +4902,7 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
eslint-plugin-import@2.32.0(@typescript-eslint/parser@6.21.0(eslint@8.0.0)(typescript@5.0.2))(eslint-import-resolver-typescript@3.10.1)(eslint@8.0.0):
|
||||
eslint-plugin-import@2.32.0(@typescript-eslint/parser@6.21.0(eslint@8.0.0)(typescript@5.0.2))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(eslint@8.0.0))(eslint@8.0.0))(eslint@8.0.0):
|
||||
dependencies:
|
||||
'@rtsao/scc': 1.1.0
|
||||
array-includes: 3.1.9
|
||||
@@ -5408,6 +5496,8 @@ snapshots:
|
||||
|
||||
math-intrinsics@1.1.0: {}
|
||||
|
||||
memory-pager@1.5.0: {}
|
||||
|
||||
merge2@1.4.1: {}
|
||||
|
||||
micromatch@4.0.8:
|
||||
@@ -5433,6 +5523,17 @@ snapshots:
|
||||
|
||||
minipass@7.1.2: {}
|
||||
|
||||
mongodb-connection-string-url@3.0.2:
|
||||
dependencies:
|
||||
'@types/whatwg-url': 11.0.5
|
||||
whatwg-url: 14.2.0
|
||||
|
||||
mongodb@6.21.0:
|
||||
dependencies:
|
||||
'@mongodb-js/saslprep': 1.4.5
|
||||
bson: 6.10.4
|
||||
mongodb-connection-string-url: 3.0.2
|
||||
|
||||
ms@2.1.3: {}
|
||||
|
||||
mz@2.7.0:
|
||||
@@ -5911,6 +6012,10 @@ snapshots:
|
||||
|
||||
source-map-js@1.2.1: {}
|
||||
|
||||
sparse-bitfield@3.0.3:
|
||||
dependencies:
|
||||
memory-pager: 1.5.0
|
||||
|
||||
stable-hash@0.0.5: {}
|
||||
|
||||
stop-iteration-iterator@1.1.0:
|
||||
@@ -6075,6 +6180,10 @@ snapshots:
|
||||
dependencies:
|
||||
is-number: 7.0.0
|
||||
|
||||
tr46@5.1.1:
|
||||
dependencies:
|
||||
punycode: 2.3.1
|
||||
|
||||
ts-api-utils@1.4.3(typescript@5.0.2):
|
||||
dependencies:
|
||||
typescript: 5.0.2
|
||||
@@ -6216,6 +6325,13 @@ snapshots:
|
||||
d3-time: 3.1.0
|
||||
d3-timer: 3.0.1
|
||||
|
||||
webidl-conversions@7.0.0: {}
|
||||
|
||||
whatwg-url@14.2.0:
|
||||
dependencies:
|
||||
tr46: 5.1.1
|
||||
webidl-conversions: 7.0.0
|
||||
|
||||
which-boxed-primitive@1.1.1:
|
||||
dependencies:
|
||||
is-bigint: 1.1.0
|
||||
|
||||
45
scripts/push-to-github.sh
Normal file
@@ -0,0 +1,45 @@
|
||||
#!/bin/bash
|
||||
# 将本地全部内容强制推送到 GitHub v0/fnvtk-5efaab9c(以本地为主)
|
||||
# Token 从卡若AI/金盾/账号密码与资料管理 读取
|
||||
# 在终端执行: bash scripts/push-to-github.sh
|
||||
|
||||
set -e
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
# 从卡若AI 账号与API索引 提取 GitHub Token
|
||||
TOKEN_FILE="$HOME/Documents/个人/卡若AI/01_卡资(金)/_团队成员/金盾/账号密码与资料管理/账号与API索引.md"
|
||||
if [ -f "$TOKEN_FILE" ]; then
|
||||
GITHUB_TOKEN=$(grep "Token.*ghp_" "$TOKEN_FILE" | sed -E "s/.*\`([^\`]+)\`.*/\1/")
|
||||
fi
|
||||
if [ -z "$GITHUB_TOKEN" ]; then
|
||||
echo "未找到 GitHub Token,使用 origin 推送(需已配置凭据)"
|
||||
PUSH_URL="origin"
|
||||
else
|
||||
PUSH_URL="https://${GITHUB_TOKEN}@github.com/fnvtk/users.git"
|
||||
echo "已从卡若AI 读取 Token"
|
||||
fi
|
||||
|
||||
echo "=== 1. 当前状态 ==="
|
||||
git status
|
||||
echo ""
|
||||
|
||||
echo "=== 2. 添加所有更改 ==="
|
||||
git add -A
|
||||
git status
|
||||
echo ""
|
||||
|
||||
echo "=== 3. 提交 ==="
|
||||
if git diff --cached --quiet; then
|
||||
echo "没有需要提交的更改"
|
||||
else
|
||||
git commit -m "chore: 以本地为准,强制同步全部内容"
|
||||
echo "提交完成"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
echo "=== 4. 强制推送到 v0/fnvtk-5efaab9c ==="
|
||||
git push --force "$PUSH_URL" main:v0/fnvtk-5efaab9c
|
||||
echo ""
|
||||
|
||||
echo "=== 完成 ==="
|
||||
echo "请检查: https://github.com/fnvtk/users/tree/v0/fnvtk-5efaab9c"
|
||||
249
services/rfm-mongodb-service.ts
Normal file
@@ -0,0 +1,249 @@
|
||||
/**
|
||||
* RFM MongoDB 服务
|
||||
* 从 MongoDB 获取真实 RFM 数据
|
||||
*/
|
||||
|
||||
import {
|
||||
getCollection,
|
||||
getRFMGroupSummary,
|
||||
getDistinctTags,
|
||||
DB_NAMES,
|
||||
COLLECTION_NAMES,
|
||||
UserValuationDoc
|
||||
} from '@/lib/mongodb'
|
||||
|
||||
export interface RFMStats {
|
||||
gradeCount: Record<string, number>
|
||||
valueCount: Record<string, number>
|
||||
totalUsers: number
|
||||
avgScore: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 RFM 分组统计(从 MongoDB)
|
||||
*/
|
||||
export async function getMongoRFMGroupSummary(): Promise<RFMStats> {
|
||||
try {
|
||||
const result = await getRFMGroupSummary()
|
||||
|
||||
// 转换为前端期望的格式
|
||||
const gradeCount: Record<string, number> = {
|
||||
'S': 0,
|
||||
'A': 0,
|
||||
'B': 0,
|
||||
'C': 0,
|
||||
'D': 0
|
||||
}
|
||||
|
||||
const valueCount: Record<string, number> = {
|
||||
'高': 0,
|
||||
'中': 0,
|
||||
'低': 0
|
||||
}
|
||||
|
||||
// 映射用户等级到 S/A/B/C/D
|
||||
for (const item of result.levels) {
|
||||
const level = item.level
|
||||
if (level === 'S' || level === '钻石用户' || level === 'S级用户') {
|
||||
gradeCount['S'] += item.count
|
||||
valueCount['高'] += item.count
|
||||
} else if (level === 'A' || level === '黄金用户' || level === 'A级用户') {
|
||||
gradeCount['A'] += item.count
|
||||
valueCount['高'] += item.count
|
||||
} else if (level === 'B' || level === '白银用户' || level === 'B级用户') {
|
||||
gradeCount['B'] += item.count
|
||||
valueCount['中'] += item.count
|
||||
} else if (level === 'C' || level === '青铜用户' || level === 'C级用户') {
|
||||
gradeCount['C'] += item.count
|
||||
valueCount['中'] += item.count
|
||||
} else {
|
||||
gradeCount['D'] += item.count
|
||||
valueCount['低'] += item.count
|
||||
}
|
||||
}
|
||||
|
||||
// 计算平均分
|
||||
const coll = await getCollection<UserValuationDoc>(DB_NAMES.KR, COLLECTION_NAMES.USER_VALUATION)
|
||||
const avgResult = await coll.aggregate([
|
||||
{ $match: { rfm_composite_score: { $exists: true, $ne: null } } },
|
||||
{ $group: { _id: null, avgScore: { $avg: '$rfm_composite_score' } } }
|
||||
]).toArray()
|
||||
|
||||
const avgScore = avgResult[0]?.avgScore || 0
|
||||
|
||||
return {
|
||||
gradeCount,
|
||||
valueCount,
|
||||
totalUsers: result.total,
|
||||
avgScore: Math.round(avgScore * 100) / 100
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('RFM stats error:', error)
|
||||
return {
|
||||
gradeCount: { S: 0, A: 0, B: 0, C: 0, D: 0 },
|
||||
valueCount: { '高': 0, '中': 0, '低': 0 },
|
||||
totalUsers: 0,
|
||||
avgScore: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户 RFM 分析
|
||||
*/
|
||||
export async function analyzeUserRFM(phone: string): Promise<{
|
||||
found: boolean
|
||||
data: any
|
||||
}> {
|
||||
try {
|
||||
const coll = await getCollection<UserValuationDoc>(DB_NAMES.KR, COLLECTION_NAMES.USER_VALUATION)
|
||||
const user = await coll.findOne({ phone })
|
||||
|
||||
if (!user) {
|
||||
return { found: false, data: null }
|
||||
}
|
||||
|
||||
// 计算 RFM 等级
|
||||
const rfmScore = user.rfm_composite_score || 0
|
||||
let grade: 'S' | 'A' | 'B' | 'C' | 'D' = 'D'
|
||||
if (rfmScore >= 4.5) grade = 'S'
|
||||
else if (rfmScore >= 3.5) grade = 'A'
|
||||
else if (rfmScore >= 2.5) grade = 'B'
|
||||
else if (rfmScore >= 1.5) grade = 'C'
|
||||
|
||||
// 计算价值等级
|
||||
let value: '高' | '中' | '低' = '低'
|
||||
if (rfmScore >= 4.0) value = '高'
|
||||
else if (rfmScore >= 2.8) value = '中'
|
||||
|
||||
// 生命周期标签
|
||||
let lifecycle = '未知'
|
||||
const rScore = user.rfm_r_score || 0
|
||||
if (rScore >= 5) lifecycle = '活跃用户'
|
||||
else if (rScore >= 3) lifecycle = '新用户'
|
||||
else if (rScore === 2) lifecycle = '沉睡用户'
|
||||
else lifecycle = '流失风险'
|
||||
|
||||
return {
|
||||
found: true,
|
||||
data: {
|
||||
user_id: phone,
|
||||
rfm_score: {
|
||||
R: user.rfm_r_score || 0,
|
||||
F: user.rfm_f_score || 0,
|
||||
M: user.rfm_m_score || 0,
|
||||
total: rfmScore,
|
||||
grade
|
||||
},
|
||||
tags: {
|
||||
lifecycle,
|
||||
value,
|
||||
behavior: user.tags || []
|
||||
},
|
||||
weights: { R: 0.3, F: 0.3, M: 0.4 },
|
||||
raw: {
|
||||
name: user.name,
|
||||
province: user.province,
|
||||
city: user.city,
|
||||
userLevel: user.user_level,
|
||||
totalAmount: user.total_amount,
|
||||
orderCount: user.order_count
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Analyze user RFM error:', error)
|
||||
return { found: false, data: null }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 RFM 标签分布
|
||||
*/
|
||||
export async function getRFMTagsDistribution(): Promise<{
|
||||
tags: { name: string, count: number }[]
|
||||
}> {
|
||||
try {
|
||||
const tags = await getDistinctTags()
|
||||
|
||||
// 对每个标签计数
|
||||
const coll = await getCollection<UserValuationDoc>(DB_NAMES.KR, COLLECTION_NAMES.USER_VALUATION)
|
||||
|
||||
const tagCounts: { name: string, count: number }[] = []
|
||||
|
||||
// 只统计前 20 个标签
|
||||
for (const tag of tags.slice(0, 20)) {
|
||||
const count = await coll.countDocuments({ tags: tag })
|
||||
tagCounts.push({ name: tag, count })
|
||||
}
|
||||
|
||||
// 按数量排序
|
||||
tagCounts.sort((a, b) => b.count - a.count)
|
||||
|
||||
return { tags: tagCounts }
|
||||
} catch (error) {
|
||||
console.error('Tags distribution error:', error)
|
||||
return { tags: [] }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 RFM 用户列表
|
||||
*/
|
||||
export async function getRFMUserList(options: {
|
||||
grade?: string
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}): Promise<{
|
||||
users: any[]
|
||||
total: number
|
||||
}> {
|
||||
const { grade, page = 1, pageSize = 20 } = options
|
||||
|
||||
try {
|
||||
const coll = await getCollection<UserValuationDoc>(DB_NAMES.KR, COLLECTION_NAMES.USER_VALUATION)
|
||||
|
||||
const query: any = {}
|
||||
|
||||
if (grade) {
|
||||
// 映射等级
|
||||
if (grade === 'S') {
|
||||
query.rfm_composite_score = { $gte: 4.5 }
|
||||
} else if (grade === 'A') {
|
||||
query.rfm_composite_score = { $gte: 3.5, $lt: 4.5 }
|
||||
} else if (grade === 'B') {
|
||||
query.rfm_composite_score = { $gte: 2.5, $lt: 3.5 }
|
||||
} else if (grade === 'C') {
|
||||
query.rfm_composite_score = { $gte: 1.5, $lt: 2.5 }
|
||||
} else {
|
||||
query.rfm_composite_score = { $lt: 1.5 }
|
||||
}
|
||||
}
|
||||
|
||||
const [users, total] = await Promise.all([
|
||||
coll.find(query)
|
||||
.skip((page - 1) * pageSize)
|
||||
.limit(pageSize)
|
||||
.sort({ rfm_composite_score: -1 })
|
||||
.toArray(),
|
||||
coll.countDocuments(query)
|
||||
])
|
||||
|
||||
return {
|
||||
users: users.map(u => ({
|
||||
id: u._id?.toString(),
|
||||
phone: u.phone,
|
||||
phone_masked: u.phone_masked || (u.phone ? `${u.phone.slice(0, 3)}****${u.phone.slice(-4)}` : ''),
|
||||
name: u.name,
|
||||
rfmScore: u.rfm_composite_score,
|
||||
userLevel: u.user_level,
|
||||
province: u.province,
|
||||
tags: u.tags
|
||||
})),
|
||||
total
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('RFM user list error:', error)
|
||||
return { users: [], total: 0 }
|
||||
}
|
||||
}
|
||||
303
开发文档/AI数智员工+神射手.txt
Normal file
@@ -0,0 +1,303 @@
|
||||
2025年12月16日 下午 4:01|1小时 12分钟 40秒
|
||||
|
||||
关键词:
|
||||
流量、标签、插件、数据源、板块、核心、算法、资产、服务器、用户画像、数据清洗、数据资产、价值模型、自然语言、任务调度、用户资产、开发设计、数据结构
|
||||
|
||||
文字记录:
|
||||
说话人 1 00:01
|
||||
工资这两天发放出去,有一笔款还没回过来。
|
||||
|
||||
说话人 1 00:15
|
||||
领投。
|
||||
|
||||
说话人 1 01:03
|
||||
这个应该现在都能访问吧?最近。
|
||||
|
||||
说话人 1 01:34
|
||||
陈路明那个部署他是不知道咋部署吗?
|
||||
|
||||
说话人 1 01:46
|
||||
那就开个会议,那个远程让他弄,要签名应该也没用,这个不是弄一下就好了,又不复杂。
|
||||
|
||||
说话人 1 02:28
|
||||
很卡吗?就这个,对。
|
||||
|
||||
说话人 1 10:22
|
||||
诶,这啥?
|
||||
|
||||
说话人 1 12:27
|
||||
就是首页。就你要看这些数据标签的这些数据,我们应该就在这个数据资产里面直接看就行了,看它是在哪一个类目。
|
||||
|
||||
说话人 2 12:45
|
||||
你项目的那些也要放。
|
||||
|
||||
说话人 1 12:47
|
||||
他项目就是标签,我们首页可能是筛选标签出来就可以了嘛。不是说你还要看什么?项目也要在上面体现,是吧?对,项目要它就是首页,它是筛选制的,它没有,那么我给你看一下,比如这个就具体一点嘛。就这边诶,就是我们现在首页,不就这个首页嘛?拿这个大的总的词嘛?总池嘛?这旁边不就这个嘛?那这边的话应该是指的项目的词,并且是可以筛选的,可以,我们就跟选千川一样,比如这个是,就是那个玩值的,对吧?然后这个是电销的,对吧?银掌柜,或者这个可能是那个小额通。
|
||||
|
||||
说话人 1 13:59
|
||||
通了,我这个我弄完之后,我在首页弄完之后,我这边是统计所有的用户数据,并且这几块可以定义的,可以自定义的嘛。然后我展开完之后,像这一个,我这个时候会出现一个需求是什么?这边有需求出来之后玩值这边比如玩值这边里面有用户会比较多,我在这些用户我要孵化另外一个私域,我就会做。
|
||||
|
||||
说话人 1 14:29
|
||||
一个操作,比如这个游戏的会跟大健康的结合,比如那个腰椎歪,腰椎疼痛的,对吧?或者那个肝不好的熬夜的,那里面的产品就很多了,这个就变成游戏加大健康,这个是游戏加金融,这个加游戏大健康的用户,我就要筛选出来一个流量池,这个流量池就是直接导到什么白金海岸的私域里面去了,让他们去加了,是吧?就直接这个,直接就导过去了白金海岸,是吧?那我在这个上面的话,操作就相对会比较容易一些。
|
||||
|
||||
说话人 1 15:30
|
||||
然后那些用户的那个评估就在这个总词这个上面,其实这个上面理论上是不变的,现在更像是个工具,知道我们还是会以那个用户为中心去做这个是第一块,他就点击进去就是筛选。
|
||||
|
||||
说话人 1 16:04
|
||||
然后第二块的话才是那个,这个就概览的这一个嘛?以这个为核心。然后第二块的话就是细细分下来的东西了。
|
||||
|
||||
说话人 1 16:27
|
||||
第二块就细后细分下来就。中台,中台这一个现在问题是不大,就中台这一个嘛?我们,诶,我把这个屏幕。
|
||||
|
||||
说话人 1 16:46
|
||||
第二块的话就是你数据中台都可以,你治理跟洗这个数据是一样的,就这一块的话就是数据源来源这个。
|
||||
|
||||
说话人 1 17:04
|
||||
然后那个数据清洗,就是现在做了一些分类,那这个数据监控跟完就这一块可能要去怎么样去整合一调?还有调度跟那个数据源这两个板块是不是能合并的?数据源那个任务调度是怎么设计的?
|
||||
|
||||
说话人 2 17:29
|
||||
要有一些去监控它的一些状态啊。就比如。我这个。质量监控就是看你整套的这个执行下来的它的一个效果是什么样的呀。你如果不知道效果,你都不知道它的结果是什么样的任务调度,这个就是它的执行的一些任务的一些情况啊。
|
||||
|
||||
说话人 1 18:00
|
||||
那这个不就变成一个小的按钮,它不需要放在这个组的那个部分了,可能就某一个页面的在清洗页面的。那你这个得。
|
||||
|
||||
说话人 2 18:10
|
||||
如果叠在那里面,那你那里面就变得二三级了,就很多了,都没必要这个,就直接那个。
|
||||
|
||||
说话人 1 18:19
|
||||
或者这个专门有一个调度的地方,不然看起来会很乱,你这边的话你就是集中很多数据的过来的地方,其实最重要的。还是数据源?
|
||||
|
||||
说话人 2 18:30
|
||||
就是接入,是吧?第二个就清洗,第三就是看整个数据的一个跑起来的一个质量情况嘛。第四个就是整个任务。
|
||||
|
||||
说话人 1 18:39
|
||||
管理嘛。那能不能结构不对?能不能这样?就是数据中台就是只有一个,这里面是这一个,包括数据清洗,它应该只是这边的几个按钮而已,是不是你应该这几个放在这个地方,不然它是变同级的,它可能就一一个一个这几个按钮,它是在。
|
||||
|
||||
说话人 2 18:59
|
||||
这本来就是同级的呀。
|
||||
|
||||
说话人 1 19:02
|
||||
清洗怎么会是同级的?它正常扭转的不是从这里进来之后开始第一步清洗和执行的情况。第一,你是不是要。
|
||||
|
||||
说话人 2 19:11
|
||||
接入吗?是吧?接入你要管理很多接入,然后进行清理啊。对。
|
||||
|
||||
说话人 1 19:18
|
||||
这像这里我们新建一个数据源,新建完之后它的执行步骤不就 123 吗?不是,所以它。它本质上就是一个标签,它没不用去展开啊。不能。
|
||||
|
||||
说话人 2 19:33
|
||||
这么说,就是看你以什么样的角度去看。因为。
|
||||
|
||||
说话人 1 19:38
|
||||
你如果你走关联形式的话,一定是走这三个往这方面走的,知道这三个都是为了这个服务的功能吧。你这个数据源就是管理。
|
||||
|
||||
说话人 2 19:53
|
||||
你的接入,就是也。一个就是你要接,是吧?第二你要清洗,第三就是查看它的效果,是。
|
||||
|
||||
说话人 1 20:05
|
||||
吧?它就是讲的是。这,这个没问题,这个没问题,就是它这个,这三个是不是为它服务的嘛?是不是这三个相当于它的插件而已嘛?为了这个数据源的整个进度?
|
||||
|
||||
说话人 2 20:19
|
||||
对呀。里面的数据源也是属于。觉得是不用去纠结。
|
||||
|
||||
说话人 1 20:28
|
||||
这个。我,我知道这个是逻辑关系的问题,以后就是。不同的逻辑,不同人看的逻辑。我,我们在。是吧?我们所有的东西都是一样,它是一定是以怎么样把用户导进来为核心的,所以这一些是以工具层面是没有问题。知道,但是你反过来看,你在以用户层面,他是你这个用户进来之后,第一步、第二步、第三步,你得有逻辑关系才是正确的,是不是?你不能是。说的那个是。
|
||||
|
||||
说话人 2 20:57
|
||||
嗯,逻辑关系,那我觉得。工具。大的模块。
|
||||
|
||||
说话人 1 21:03
|
||||
这你按模块分类是没问题,就是你要以想的是咱们这一个东西一定是用户为中心的,它只是用户为中心,而不是一个工具去查看整个系统的运转的程度和数据库运转的程度。那个没必要,那只是一个面板而已,我们要的整个的东西一定是它可应用度要很高的。比如我现在刚刚那个界面,我拉一下它这边是不是能直线在运转的一个状态?是不是我能知道哪里出错?或者他清洗的一些东西?或者他是我们分析出他需要什么类型的,那个什么类型的数据,对吧?我们现在可能这个什么地方、什么类型的数据能分析出来,能让我们去对接出来,对吧?哪里有或者哪里有这类型的数据能对过来?这个是结果性的东西。不是,不是深不深的问题,我不是说深的问题,这个不是深的问题,是这一个数据,这个你可以这么理解啊。
|
||||
|
||||
说话人 1 22:09
|
||||
首先这一个是用户池,我们的大池就一类是大池。对啊。这个是支流,这个山川什么流进来的?那流进来的,你是不是流进来的?那个门闸也好,垃圾治理也好, 7 的 8 的治理也好,这些都是为了服务这个流进来这条线的清洁度,是不是那本质上我们。我们只是第二个功能,只是为了让它流进来,所以流进来我不可能去关注它。再建个大坝,再建一个,再让谁去工人去清理河道,再去什么什么?这些我不管,我只管你流进来。这条线最终是不是我要的纯净水?那你怎么中间用的什么样的工具我就不管了,知道吧?我可以去,你就自由地去调配,但是我是不管你这个东西是怎么用的,那这个整个池子它。
|
||||
|
||||
说话人 1 23:00
|
||||
的这个用户进来,对吧?你这个是大池子,这个是我们第一个的流量池,大的池子嘛。那第二个的数据中台它核心一点是什么?我这边有很多条线,你这个是我这个流量池需要什么?我们需要什么?它分配什么嘛?这个就是支线的支线流量,支线的用户流量。那我只要卡,卡一个啥?你这个支线的用户。流量是不是?对,符合我这个标签,符合你才能进来,不符合我我不让你进来。
|
||||
|
||||
说话人 1 23:35
|
||||
知道,那你这个,你想达到我符合的这一些东西,你去生产插件,ABC、d、e、f、g,不只是这几个插件,以后可能有无数个插件,我不管的这个都是一些模块,你怎么样去过滤把?我们之前不是有做过那个京东的什么什么这些东西吗?你怎么过滤什么用工具我就不管的,你只要达到我的要求你才能进来,只是现在是我们做的要求。是符合我们自己的要求,知道这个是这一个板块。
|
||||
|
||||
说话人 1 24:12
|
||||
不是,这个是基础架构,不是细,因为现在看不是页面,是底层的架构,你知道如果是纯粹调页面,咱们讨论这个。是没有意义,这个这版就过了,知道吗?为什么要拎开另外来讨论这个事情,知道吗?它是。你放在那边。
|
||||
|
||||
说话人 2 24:37
|
||||
跟你放在那个。
|
||||
|
||||
说话人 1 24:39
|
||||
数据源里面差别不大的。我,我不是说页面的调整,我是指的是什么?这个数据源也好,你不要不是叫数据治理,我们不是为了去调整合这一个治理,不是治理,只是过程,它只是工具,知道吗?它不是我们要的结果。我们现在要的是这个数据流进来的,最终流进来它是有效的,能对过来是有效的。所以增加很多插件,我们增加很多插件或者标签化或者 AI 的这些功能,它只是来服务这条支线的这个流量池进来的这个是大的池子,这个是外部的池子,这个是规则,规则是我们自定义的规则,这个是很重要的一个点,你看如果是这一个功能。
|
||||
|
||||
说话人 1 25:29
|
||||
回头他们要开发一个功能出来,那就不一样了,他们要开发一个功能,就是这个功能就是去数据库里面去筛选指定的数据或分析指定的数据报告,不是的开发一个功能,我是我们要符合这个要求,所以这个功能是在这个用户里面去筛选出精准的符合条线的池子,可能这里面留个 1, 000 万条,我最终通过筛选,我只要1万条,那这个外部的进来1万条是我要的,其他我不要。
|
||||
|
||||
说话人 1 25:59
|
||||
而不是我去查外部的那个流量池过来,是吧?我如果去查外部的流量池是没有意义的,是不是它一定是服务支线的呀?讨论的重点是我们就神射手,它是以用户的流量、用户画像为核心的一个事情。知道它是用户资产平台,所以我们全部都是以。用户画像以及用户画像形成的用户池子为核心在讨论,知道不是我们在,不然它就变成一个纯粹的查询工具,那查询工具我现在用 AI 更快,我直接在本地用 AI 绝对比这个还快,是不是?我提示词全部写好了?是不是?那它就是没有意义了?这个东西第一个我们能查,第二个我们能知道整个,比如我说一个需求。
|
||||
|
||||
说话人 1 26:59
|
||||
求对吧?我现在要知道厦门这边哪个地方卖那个烤串最好卖,转化率最高,是不是?我肯定,如果我是直接问 AI 的情况下,我们这个中台能不能发挥这个作用?对,这个作为一个底层的支撑,对吧?那他底层支撑,我提这一句话,他肯定是先到这个。流量池里面去找,是吧?他肯定是在这个里面去找相应的标签出来的,然后再推送给我呢。他在推送什么用户?比如五里那边,对吧?五里厚普那边吃烤串的最火,那因为他有1万个用户,经常吃的有1万个用户。其他地方可能只有 3, 000 或者 5, 000 个用户,那么他是要作为他的查询的底层的支撑的。这个里面也有啊。里里面有,是我们现在可以通过去跟他聊天,所以才有这个,我看到了。
|
||||
|
||||
说话人 1 28:30
|
||||
我,我的意思是什么?这个我们现在做的这一个东西,包括展示的东西跟逻辑关系都是以用户为核心的,那以这个为核心的目的是让我们以后能提能对话的时候,它才能做支撑,它才能给我输出数据。对啊。然后对,这个是等一下一个板块要聊的,所以这个支线的这一个。链路这一个用户是我们是要有很多的,就是你的数据中台数据清洗,包括 7 的、 8 的数据叠加或者那个,对吧?对,过来这一些都是为了清洗之前链路进来的用户,我们有多少用户能进来?
|
||||
|
||||
说话人 2 29:19
|
||||
比如我。这也是需要管理的。
|
||||
|
||||
说话人 1 29:24
|
||||
这个就你管理不都做好了,他只是整理一下逻辑结构,因为。这个跟什么有关系?跟逻辑结构有关系?不是跟那个,跟其他的东西那个有关联,不是的,知道吗?我跟你讲一个,这开发下去的时候,到时候要扩展开发会非常麻烦,我当时我反复强调还是会开发歪掉,你知道吗?我说一个其他的例子,之前存客宝都是工具,每一个都是点,最早的一个版本是无限的。工具的,那现在不一样,现在以设备为核心和用设备里面用户为核心就两个事情没了,他们就是去开发微信底下的功能,而不是设备一块,微信一块、发朋友圈一块、数据统计一块、财务自理一块。类。
|
||||
|
||||
说话人 2 30:19
|
||||
的方法、归类的方法不一样,但是它的底层逻辑还是一个一个。
|
||||
|
||||
说话人 1 30:27
|
||||
底逻,是的,底层。逻辑是一个功能点,它思考方式不一样,他们到时候会解释不清楚,就是我要发起一个需求,不,不是界面,不是你到时候它设计开发设计的时候会出现问题的,你就是我们一开始就要归类清楚第一个这一个是什么?第一个板块是什么,对吧?大的一个,一个流量池以及流量池的一个属性。
|
||||
|
||||
说话人 1 30:53
|
||||
第二个板块就是汇总数据以及汇总数据的筛选,你能一句话讲清楚。这个事情第三个,这个不是流量的一个问题嘛?你第三个不就是那个你,我们对一下你就知道了嘛。第三个你不是标签画像吗?跟价值模型吗?其实标签画像跟那个价值模型这两个应该也是拼在一起的第三个板块,我跟你讲它一点是什么?流量都进来了,那里面是干嘛?我重新起一个,你看我们这个流量池都进来,都。汇总了第三个板块是干嘛?这里面的流量池的归类方式都是各种标签或者标签中的叠加,它是这个样的,不管你是 AI 分类还是原有的标签体系,还有 7 的、 8 的这几个标签分类的形式全是一个东西的,你可能标签的模型a、b、c、d,这个跟第二个是一样。
|
||||
|
||||
说话人 1 31:59
|
||||
第二个是标签慢那个流量池的清洗,那这个也是相当于在做归类,它不是清洗,它在归做归类,我们只是归类的方法,是用 AI 归类还是用标签归类,还是用其他形式来做归类?那归类的核心还是一样给我们做那个用户资产做支撑,因为它归类好了,我们可以快速地去找到,并且点一下就能知道,比如这里这边是爱吃地摊,就樊登的。
|
||||
|
||||
说话人 1 32:29
|
||||
这一块我就能直接拎出来了,它查询速度是不一样的,这个是包,所以它这个东西值多少钱嘛?它的价值模型,你价值模型不是独立的一个标签,对吧?这一个私域值多少钱?然后有什么样的标签?它是就会变成你这个就变成一个对象了,它这个圈圈拎出来它就有属性了,你能描述清楚。
|
||||
|
||||
说话人 1 32:59
|
||||
属这个点它就有属性了,有 IFM 值多少,对吧? AI 的有什么属性?有什么属性它就能产生属性了。这个你能解释得清晰,那他们在,老王他们在设计结构的时候就比较容易,因为不然数据它的词条会很多的。我看了那数据库,知道存客宝这个回头还要把那些数据抛先重新整理,因为。它们全是横向的,现在数据库全是这样的,它是不支撑不起查询的,一定会出问题的。正常的数据一定是分类好之后,底下它是立体的品类,它是立体的标签,这个才是正常的。现在还没到量化的那个数据库,知道向量数据库还没到,知道吗?这查询会出问题的。特别这个数据一方面没有做归类,如果是传统的模型,你工具当然一张表设计没有关系,但你一多的时候你没有相关联,这些表是没有相关联的,会出问题的。
|
||||
|
||||
说话人 1 34:16
|
||||
他查一个为什么老王跟我说查一个 7 天,我查一个 40 秒,他是不一样的,数据结构是不一样的, 7 天跟 40 秒是天差地别的,这才1亿条数。数据知道,因为他一张表去翻这个就出问题了。我,所以我现在让他们从 Mysql 变成那个 Mongo 数据库,这只是那个基础,所以我们在搭这个的时候可能得注意一下,就是那个标签的那个画像,当然那个标签体系这些功能都出来,只是这个逻辑关系怎么样去解释他们会更。更清楚一些,不然这个会跟查询速度一定会受影响。
|
||||
|
||||
说话人 1 35:04
|
||||
就你查过来,我查的本来查的是一块,查的一块才会比较快,就是我查的这个东西就是厦门的点的妈的私域,这一整块我就拎出来,然后点的妈私域里面有什么什么什么什么什么,它是一个立体,我像查字典一样,我们可以很精准的去查,是吧?找到点的妈三个字直接。在里面翻每年消费每个月消费 1, 000 块的用户,我就直接拎出来结束了。那如果你是用这个表的形式的话,他是在几亿条数据里面去一张张查,现在用那个 MySQL 服务器为什么经常崩?后面换就是一条在查那巨耗能,是吧?你再怎么大的服务器也支撑不起,会出现这种问题,知道吧。
|
||||
|
||||
说话人 1 36:03
|
||||
那这个里面也是一样,你当然你这个这逻辑关系的问题,你这个用户画像就最终点进去,你看这里是不是点的嘛?他这样设计才会有结构感点的嘛,过来之后不就这个嘛点的嘛?整个私域的那个总的消费额度,对吧?客户画像私域的产出产值是多。多少?平均是多少?这些就是它的那个维度,到时候我们要调,就有很多数据可以调了。
|
||||
|
||||
说话人 1 36:36
|
||||
那我点了吗?我点击进去点了吗?之后是地摊的这个人的画像,就你现在不是做好吗?这个真正的那个用户画像再出来吗?对吧?那这个用户画像下去的话,他的一些人设朋友圈呐?他又有属性了,其他设计起来就。非常简洁了,一个一个外流,第三个板块是内流的点击和展开,它只是点击跟展开而已。
|
||||
|
||||
说话人 1 37:08
|
||||
我这边点击展开就有点的吗?一堆点的吗?展开就有一堆用户画像没了,那所有东西都是来服务这个用户画像的所有的功能,我包括我们后面扩展的什么功能都是,都,你可以都理解,都是插件,都是为了来服务它。更好地去扩展它的标签的,包括你。我们设计的很多算法也好, 7 的、 8 的只是为了把这个归类归出来,归得更细,把它的分母那个变得更小,更容易去做筛选。不然他会很容易那个这样容易找,因为你如果你去找查一个人的情况,那太复杂了。你这边几千万条,你要这一个人可能1万个标签,当你出现1万个标签的时候根本就没法查,你查1万个人服务器都白白了。那我要查这个1万个人,我就找到点了,就结束了,后边这一张表就过来,再差一点,再找另外一个符合这个类似的,找第二张表跟它一并直接出来了,它就两步,那两步完之后我查找完之后再。
|
||||
|
||||
说话人 1 38:29
|
||||
插件干嘛的呢?刚刚说的插件再进来这个里面去计算,我两个查中间的这一个出来,对吧?我这边再算法,再继续计算。那这个就很快了,他就只计算那一撮,不然第一步查询就卡住了。这个这一方面的,我现在是让他们快速先去了解这些数据库的。结构,不然处理起来无限麻烦,是吧?包括我现在自己在写那个查询,为什么就老王有一天问我为什么会我查这么快,几十秒就出来了?我能漏一个人,我就几十秒就非常快,是吧?是因为我里面我就做了字典,没其他的知道,其实这个,这个其实也是一个字典的一个一个功能快速查找用户。
|
||||
|
||||
说话人 1 39:30
|
||||
用户的池子,那我们所有的插件的功能只是为了把它做更好的归类,里面的归类越多,你看千川为什么?早期二,千川是 21 年 4 月份出来的,千川出来早期是所有的流量池,它早期就这个千川,早期就这个混沌的一个状态,没有其他的,那为什么千川现在能这么值钱呢?是吧?千川的核心逻辑它在这个底下,对吧?它一开始没有这些圈圈,都是一些基础,跟我们这个一样的混沌状态,很多只有基础的标签,那千川做完之后,这个是千川,千川做完之后很多人在跑视频,是不是在跑数据?点赞、评论、转发在跑数据,在帮它搭流量,池千川所有的直播带货的公司都在帮千川搞流量,池千川就不断地把人。
|
||||
|
||||
说话人 1 40:30
|
||||
把人拉到这边,推到他们的这个,比如这个直播间的流量池里面去,那直这个直播间里面的流量池或者视频的流量池,再去看他们的购买率评论、点赞、转发,对吧?那这个客户是有效的,他就只再给你加到这个属于高价值人群,他就出现一个高价值,以后这一帮人买了纺织的或者喜欢买什么的,他就到这个池子里。
|
||||
|
||||
说话人 1 40:59
|
||||
一遍的,那我千川投流,我就把这个池子丢给投流的直播间,他是不是转化率就高很多了?千川就一直在洗,那现在千川很成熟了,全部都是付费流量,你就是付费流量,就是把这些投到你的直播,把你的直播间你投钱,我就给你这个,你们十个人去竞价,看谁的转化率高,千川的底层就是这个没了,是吧?那他要解决高频。
|
||||
|
||||
说话人 1 41:29
|
||||
呢?千川要解决这个是不够的,我们这个只是低成本的行方式,千川要 0.001 秒的反应,我还得 40 秒,对吧?所以他要做分布式的服务器,这个就另外一回事了,对吧?分布式的数据库,那我们现在用这个 Mongo 是可以实现分布式的,只是它成本会很重而已。那这个后期挣钱了再说前期不考虑是这一点,但是前期我们一定是考虑。
|
||||
|
||||
说话人 1 41:59
|
||||
这个流量池的归类的问题才能实现。比如这个词,你在他们每天,我举个例子,银掌柜,这个是银掌柜,我每,我们每天给,这是金融的,金融的,厦门有做金融贷款的,我导到银掌柜的手机里面,那银掌柜通过手机打电话超过 30 秒,并且有付款意向的就会进到这个流量池,说明他有贷款需求。那么以后银。
|
||||
|
||||
说话人 1 42:29
|
||||
掌柜要开另外一个业务的时候,我是不把这个流量池直接导到那个银掌柜的那个里面去就可以了,他其他的手机就可以,他就不用每天打 300 个电话,他打了 300 个电话,这边筛选完之后可是这边的 10 倍,他也可以给其他便宜劳动力去打,那这个就很快了,就筛选很快了,对吧?这一些就会越来越。
|
||||
|
||||
说话人 1 42:59
|
||||
清晰的标签越来越多,所以我们在标签回流跟叠加的这一块就是要比较灵活了,是吧?这个是筛选的这一个,这第三部分的事情,这有什么疑问没有?反正我先过一遍,我们等一下可以探讨一下这个事情,因为我会考虑他开发的可能会碰到的一些问。问题,因为我们如这个肯定往下的,不然一定又数据等你跑了,非常非常多数据的时候,然后我们再去重构,那这个就大大调了,这个就不是小小事情了,知道吧?事情就很多了,我现在也是,所以我这两天为什么想让永平他们在设计的过程当。当中出那个架构图就出的时候,我就知道里面到底是怎么回事,他不出,按照他们的经验去跑,我已经发现很多了,一直在改,做完这一版可能又要改,就很麻烦。对,但我们提前给他罗列好就不会有这么多那个改来改很多版的一个问题,改是避免不了,但是我们尽可能知道的情况下不改嘛。
|
||||
|
||||
说话人 1 44:37
|
||||
所以你这个就要多出几张这种图,这种逻辑,我等一下讲完,你可这 5 个板块可能就是 10 张图就解释完把,然后把流程图都弄一下嘛?然后再给改一下。你的意思改一下,然后再去做界面的调整,它其实不是界面,你这些功能都实现了,都已经完成了,我觉得这个没太大问题,只是。你把这些问那些功能怎么样?那个让它是有逻辑性的地去跑,对吧?所以这我是想的一点是什么?这个标签画像的都是为了用户的池子服务的,知道我们这一块等于在做什么?私域版的千川,私域版的千川,这个是我先把其他的都说完,我们。再等一下,再碰,诶,你能看到界面吗?飞书啊。
|
||||
|
||||
说话人 1 45:45
|
||||
我知道,但要录下来啊。
|
||||
|
||||
说话人 1 45:55
|
||||
所以一个一个是汇总看的。一个是外部流量进来的,另外一个是内部的流量的那个整理跟切割就价可能价值模型跟标签画像这两个就是变成用户画像了,也本质上就是用户画像和一个数概览,一个数据中台,一个用户画像就三个,然后你那个另外一点是 AI 的这一个板块。
|
||||
|
||||
说话人 1 46:29
|
||||
快嘛? AI 的这个,它是这样的,就 AI 的这一块是要变成什么?变成一个那个类似于那个 AI agent 的那个功能,就你可以把这一个 AI 其实它是抽象成一个一个人,一个人。这个人就是我,我们可能就是跟他直接对话,是吧?直接对话,是吧?然后我们跟他对话完之后,他是可以就调用我们前三个板块的,在这个里面去更细化。
|
||||
|
||||
说话人 2 47:18
|
||||
自然语言查询。
|
||||
|
||||
说话人 1 47:19
|
||||
一个,对,一个自然语言查询,这是一块,第二个的话它是应该是一个,一个那个。那个 agent 的集合,理解,它就是一个 agent 的集合,我们你看为前几天让你们去看那个宁光,宁光其实它的本质上它也在搭agent,它其实不成熟的,它这个 AI 生成应用的就是在搭agent,就是在给它的数,那个数据跟它的那一些那些用户的那个。
|
||||
|
||||
说话人 1 47:59
|
||||
去优化它整个东西,它里面就会有什么,你数智员工,不是数智员工那个数据的一个 AI 的一个agent,我们这个是可以自建 agent 的数据的一个agent,是吧?它就是一个集合,比如我现在一个一个要做一个,就我们原来不是有。有弄这个吗?这个进来提问,正常逻辑就是提问,然后提问向他提问,就这里他的模块就是提问,第一个就提问,提问完之后就知识库,知识库其实就是数据库,是吧?就通过我们的数据库,对吧?然后再往后才会到那个就输出表格或者样式。或者网站或者那个流量池,它每一个的功能,它是这个,那这里面是我们定义好的agent,你里面其实已经有很多的,比如它做一些数据清洗的, AI 数据清洗的嘛。
|
||||
|
||||
说话人 1 49:21
|
||||
的agent,那这个提问中间它有个提示词,那这个流量池弄完之后是不是他自己导到他自己的存?存客宝里面去,或者他微信里面,通过存客宝找到他的微信或者公司这个用户就挪过去了。是不是他这个提问应该是往这方面去走的,对吧?你现在生成那个 agent 的模板和东西其实已经蛮多了,就怎么样去。
|
||||
|
||||
说话人 1 49:59
|
||||
这种嘛?然后那个规则跟另外一个的话,那个智能的预测,你不是有一个智能预测嘛,对吧?这几个板块其实你都已经做完了,只是它是一个集合,我们是从集合就等于智能预测啥,对吧?预测消费能力,对吧?预测它的那个消费周期,就像现在他们做。做那个什么游戏的游戏?他们就 3 月份、 6 月份、 9 月份、 12 月份,每每三个月一波,对吧?他就很容易去预测为啥?因为他浏览量是最多的,购买是最多的,用户活跃度是最高的, 369 立即就知道了,但这个模型不是我们来建为主,为什么宁光他要把这一些 agent 的对外放?他是让你在帮他们。
|
||||
|
||||
说话人 1 50:59
|
||||
清洗所有人,全网的人在帮他们清洗,他们的中间核心是数据核心,不是这些表面上的工具,表面上工具都是你可以创造无数,我们只是这里就制定一个 agent 的规则,一定不能走向工具型,我们如果做好工具,这种无数的工具怎么可能做好?我们就做好一个生成工具的规则就可以了,对吧?所以它本质上也是一样的,第四个板块也是一样, AI 跟 agent 去提问清洗,这个是数据库也,你也可以说是流量池,数据的,流量池的包我以前要去查一个,就像我就发现那个京东之前有一个套利,是干嘛呢?我做一个事情。
|
||||
|
||||
说话人 1 51:59
|
||||
去9月,每年的9月十五号是苹果发布日,知道吗?这9月 15 号是苹果发行的日期,那我干嘛呢?我为什么知道这个事情呢?我是去看了百度指数,对吧?阿里的指数我才知道诶,这个突然搜苹果两个字大涨,对吧?我是知道的这一个这个事情,所以我才去在九月一号到九月十五号之前。
|
||||
|
||||
说话人 1 52:29
|
||||
我是去把淘宝的商品宝贝给他放上去,让大家预售,我做预售让很多人他直接买我这个东西,买完我这个东西之后十五号就发布了,那我手上已经有淘宝很多的订单,我直接淘宝的订单直接就能贷款出来,然后直接来买货给他们。那我就看破不破发,我等个 5 天, 10 天交付,对吧?一破发那就 20% 利润。就囤苹果就行了,而且我都不用钱,直接就用他们的钱去淘宝那边那个带那个货款出来,直接买了就挣 20% 利差。
|
||||
|
||||
说话人 1 53:08
|
||||
那如果没有我就退钱给他就行,我没有成本的知道那这个怎么来的?这个就是从这些数据里边来的,所以现在有这个东西他能发现更多的东西了,然后我们这个 agent 就能干嘛?是不是放到存客宝里面去就可以了?一个工作台的一个功能。可以,或者直接在首页上面的一个提问就可以,它做底层的支撑,但是它是查查数据的,我们后台就能看到很多什么什么的 agent 的一些归类,各种的 agent 的归类,然后刚我们弄的很多的标签也好,这些都是作为这一些的底层的东西基础的这个 agent 的。底层的东西知道吧。
|
||||
|
||||
说话人 1 54:13
|
||||
自然语言查询它也是一样,它不就提问在这,那它是里面的功能模块,它在这是不是它就在这?然后这个的话就是我们第二个板块的东。东西,知识库、数据库第二个板块清洗出来东西,这个提问清洗出来东西,最后第四个板块才,它才形成简易的流量池。我们在第二个板块这个数据流的过程当中,它还只是普通的,要是可能都要手动或者查询而已,对吧?还没有那么的标准嘛。比如在存客宝,我要 1, 000 个金融的客户, 1, 000 个金融的客户出现。变成一个流量池的包,然后他付款结束,是吧?或者他付款直接导到场景获客里边去加一个多少钱,这个就变活了,他本质上搞这个东西是为了让整个组织是活的,是吧?然后再最后一个,就是比较重要的那个数智资产,是吧?
|
||||
|
||||
说话人 1 55:44
|
||||
就一个那个数据资产有一个其实也是一个逻辑的,你可以理解成数据资产就是一个项目包,知道吗?我们的数据资产是一个用户的项目。项目包什么项目?值什么钱?多少钱?具象化的那个项目值多少钱?然后这里面就是很多项目,银掌柜的项目跟标签分类最大的区别是这个项目出来之后,它是一个项目的价值包,就它的整个项目值多少钱, IFM 值多少钱?然后有多少人。那比如有这个维度,有多少人投了这个数字资产?就像我们现在就一个项目,就有三四个人投嘛?每一个 20 ~ 100 万,对吧?那这个人他投了多少?里面有多少个用户?值多少钱,对吧?比如这个银掌柜现在三个股东,每个股东投了 100 万,那 100 万他们交付的结果是什么?交付的结果是那个就总的是 600 万嘛。交付的结果是就是一个用户干到一年能有 600 块的利润,那他有1万个用户就结束了银掌柜的目标,就干这个事情,他的总盘就值 600 万启动,那这个项目很好很好找你从你做 600 万的启动的过程当中,你直接就是展示给有一个页面,因为这个是后边的那个板块嘛。
|
||||
|
||||
说话人 1 57:29
|
||||
做数据资产投资的另外一个APP,那这个 APPAPP 弄完之后,你是知道项目展开有什么样的人投?有这个项目里面有什么样的团队,对吧?然后他的单粉产值多少,对吧?那这个都是上面的 1234 去提供的数据做支撑,一个单粉产值,一个用户的那个估值,一个是这个是实际的产值,然后多少人投?然后他。们的收益率,对吧?就等于这个,你可以理解,这个项目包就是一个面上的证券公司,他我们这一个只是数据做支撑,后面开发的时候是直接可以调用的。我要搞这个 APP 做出来就是把这一些有多少个项目,这个项目归类到这个里面,然后直接让别人投就完了,线上直接投就完了,不然他们现在那。一个他们几个?他们不是投了 300 个了吗?那么这几个投了 300 个,这几个他是里面有多少人?现在的实际的产值是不清楚的,知道,只知道现在每天发了 7 台手机出去,每天每一个人能加 3 个人,那每天有 20 个客户来公司,就那观音山那边,对吧?每天就 20 个左右,平均他们来公司,对吧?那他这里。
|
||||
|
||||
说话人 1 58:59
|
||||
的话,目标肯定你要达到1万个客资,那长着嘞,知道吧?我就很清楚这个时候还要融 300 万,达到1万个客资,我是目的就是要把这个手机现在放了 7 台,我放 70 台,放大 10 倍,对吧?那一天 200 个,对吧? 30 天就有 6, 000 个了,买 600 个了,对吧?一年就 7, 000 多个了,一年就差不多结束战斗了,大家才敢投。钱,那他里面看,诶,这个收益就像上个月放了 700 多万出去,那这个可以,对吧?那这一个就可以能算得清楚了,这个是为了能大家能看得可视化多一些,里面就是一些细节嘛。所以这一个点就是我们要展开出来,然后里面是干嘛?你可以理解他的单粉产值也好,或者以后有什么 CLEM 的。
|
||||
|
||||
说话人 1 59:59
|
||||
GLV 的模型或者 RFM 的模型,估值模型跟这个用户生命周期的模型,它只是给这个项目做一个算法的清洗而已,这个就是一些插件,所以它以后的结构就很简单了,一个大池加上小池加上清洗的插件没了,每个板块都是一样,那他们很。很容易抽象,对吧?他们很容易抽象出来,或者算法插件没了所有的东西结构都是一样的,所有五个板块都是一样的,大词小词,清洗插件没了算法,他们去架构起来,这几个板块都是命名,都一致了,都一致,所以就只有三张大表。然后以后下面可能就是 6 张左右的小表,那底下全部是就那个向量的数据库去做查询,很快跟查字典一样,你查个人三次就查到了,没定位一个人多简单,对吧?中国福建,然后这1万个人去搜,对吧?那你交叉,有交叉那就更好找了,对吧?能找。一下就两下就找到了,就这个是为了方便他们去理解这个是怎么回事而设计的嘛?等我们这样弄下来会结构会清晰,那五个板块是,这五个板块是刚好是赋能我们现在五个 APP 的,以及未来的所有的 APP 就很好做了。我要搞一个啥东西?那太容易了,这几个我就把这几个板块我就调一下数据就出来了,甚至就 API 调一下就出来什么 APP 都是很好很好做的,他不会去,不会卡壳的就可以无限生产。像那个刚刚那个版版,那个版那个 agent 那个版本,是吧?然后那个系统监测这一些功能,我觉得这一些就是直接。
|
||||
|
||||
说话人 1 01:02:29
|
||||
放到这个里面去就行了,不是为了工具,这个本质上是为了把这个那个体系做完善,是吧?然后扩展性后面那些插件式的东西都是可扩展的,是吧?所以就一个一个回流,一个池几池子里面就跟钓鱼似的。是的,你去筛选另外一个的话,就是他的那一个。那一个 AI 的那个 agent 的那个设计跟选择,是吧?这几个板块其实弄好就差不多了。那你看我,我现在在做一些东西你就知道了,比如我。你看我最近看像这种,我不搞了,本书,写的本书。那写书。书之后我就直接让很多做售的那些人直接去帮我分发,里面就是 37 个人分享他最赚钱的一个月,我写的这个书写完之后所有做切片的我卖 9 块9,全部人 9 块 9 全部拿走。
|
||||
|
||||
说话人 1 01:03:42
|
||||
我做这个的过程当中为的是什么?为的是获客他们买完书之后是直接是到我的那个 KR 的那个后台的,是吧?退出一下,对吧?他们就一个一块钱,然后分销全得。有购买就购买,分销全得。那我做这个干嘛呢?做这个网站就是为了来找什么这个人他在我的网站上面付款,他是等于这一个进完之后是到这里面来的流量池里面来的,对吧?可能他有这个有新的网站,也算是网,不是agent。网站也算是一个流量池,对吧?这个 APP 可能就是一个流量池,不管是易思的还是我的,他是不是点击进来?那这边是不是我算法?其实就是我的网站看了什么什么东西,打了什么什么标签,进来之后他在这个流量池里边一筛选,我就立即知道这一个人他的所有画像,甚至他住哪里,我是非常清晰这一些的。那后台我们直接就完善数据。去了,知道吧?我就知道谁买的这个书,派对里面这个谁?他玩过什么什么游戏? 7 的, 8 的这个后台的数据一完善是很清楚的,对吧?这个才会有价值,而不是买书。
|
||||
|
||||
说话人 1 01:05:15
|
||||
我无非这些东西我都可以送的,这些全是送的,对吧?我每天写都是送给他们的,对吧?让他们反正一天一块钱,对吧?还有像那个就这种,你做这种网站出来是很快的。
|
||||
|
||||
说话人 1 01:05:52
|
||||
就是本质上可以做所有的网站,像搞这种是。是连发布都连一小时都不用,不需,都不需要一小时就可以直接发布到那个春节要到了,你看这个,对吧?这个是我另外一个朋友要做的,但我要看他做不做的话才往下做。这个跟为什么我让陈路明做那个MBTI?我要看一下速度怎么样?像这种做出来这个是非常快的,在就像 80% 能用的就直接上了,对吧?像这个就一个他们有。宣传的渠道它是那个,嗯,就美俏的那个,你是不是跟我去过美俏?方老师?对。它这个就是一个,对,就剪头发后悔就玩三个月嘛?那这个东西你就上传一个照片就可以了,对吧?我只是没对 AI 而已,我搞一个照片。对吧?我只是没对接 AI 而已,他就上传。
|
||||
|
||||
说话人 1 01:07:06
|
||||
那我就知道他什么发型,剪的什么头发,他自己选发型,对吧?有剪选这个,没?对,AI,他自己生成这大模型,弄完然后付 5 块钱,不然就是模糊的。他是不是想看付 5 块钱就进到我们流量池了?那进来,我是不是能知道这个人的?所有的东西就是他分享还能得两块5,有人付款他还能得两块5,像这种项目你直接丢上去是自己就裂变,因为过年了,马上过年了,那以前搞这个这种项目是要很久的,现在就咔咔咔几下就搞完了。对,然后也挣钱,顺便挣钱。
|
||||
|
||||
说话人 1 01:07:57
|
||||
我那天为什么让你去?先做那个SOP, SOP 就来做这个的,搞这个连发布一键发布是非常快的,你知道吧?发布到宝塔,我今天还在弄,还没弄完,我为什么那个,这个,你看我在干嘛呢?这一个是可以自动发布,一键你 V0 生成,直接 Github 下载,在本地直接一键上传部署到那个宝塔的服务。器上面知道吧?那你后边就非常快了,对,这个还没完成。对呀,本质上这个就是智能体,为什么我强调那个智能体?现在就这样啊,这个就差那个服务器有点问题,连不上,不然都已经能实现了,我弄完我再给你们去做分享,知道,那这个。
|
||||
|
||||
说话人 1 01:08:59
|
||||
这个我只这些是要去往下去去落的有很多个板块,但是我们现在的话是有一点是什么?就把它理清楚,理简单了,事情看起来复杂,但其实不复杂,不会太复杂。包括那个我一,我现在也是想看看陈录明的速度,现在两两个礼拜,两个半了,两个半礼拜了,对吧?就说明他的搞一个这种 APP 的时间是两个半礼拜的时间。是吧?我而且不仅仅你催,小吉也催,天天催那4,实际就两个半礼拜我就能知道他的效率就是一个 APP 两个半礼拜,要么就三个礼拜左右,两到三个礼拜。
|
||||
|
||||
说话人 1 01:09:50
|
||||
这个是题外话,我只是讲了一下这几个板块,那你就一个板块,一个流程图跟一个解释的出来就行了嘛。然后我们再弄一版,我们再碰一。一下这就完事了,是不是还有一个,这个就先过,你要拍吗?这个要拍吗?
|
||||
|
||||
说话人 1 01:10:14
|
||||
开V0。这个没有,是吧?没有啥。
|
||||
|
||||
说话人 2 01:10:20
|
||||
你这个他录里面录不到,是吧?
|
||||
|
||||
说话人 1 01:10:22
|
||||
这个录不到,这个我没弄这个也写的这个用来干嘛?你知道吗?你先拍,你这个可以,弄完可以发产品群里面。
|
||||
|
||||
说话人 1 01:10:51
|
||||
100 多笔。这个是干嘛的?我给你看点一下变脑图。直接就能发到邮件里面去了。有的。
|
||||
|
||||
说话人 2 01:11:02
|
||||
看到看不清楚它的字面。
|
||||
|
||||
说话人 1 01:11:05
|
||||
不能完全识别,嗯,这个版应该好像还没更新,还没发布,这个能正常识别了。
|
||||
|
||||
说话人 1 01:11:28
|
||||
现在都是直。一定要用 AI 的,把很多东西都这个也 AI 写的,它还有一个功能很屌的,你知道这个干嘛的吗?这个是截图吗?不是,这个是手势的,这摄像头是坏的,你在这边可以隔空画的,就是它识别有点问题,它要就有点荧光,我还没弄,但我没有,我不会花太多精力去搞这个东西,知道吗?这种是很方便的,然后你点一下就发到邮件里面去了。好,好。
|
||||
|
||||
132
开发文档/v0-神射手集成指南.md
Normal file
@@ -0,0 +1,132 @@
|
||||
# 神射手前后端集成指南
|
||||
|
||||
> 神射手前端项目与卡若AI神射手Skill后端的完整集成方案
|
||||
|
||||
## 项目概览
|
||||
|
||||
| 组件 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| **前端** | `/Users/karuo/Documents/开发/2、私域银行/神射手` | Next.js 14 应用 |
|
||||
| **后端Skill** | `/Users/karuo/Documents/个人/卡若AI/04_卡火(火)/_团队成员/火炬/神射手` | Python 查询引擎 |
|
||||
| **数据库** | MongoDB Docker | 20亿+ 用户记录,207GB |
|
||||
|
||||
## 已完成的集成
|
||||
|
||||
### 1. MongoDB 连接器
|
||||
|
||||
**文件**: `lib/mongodb.ts`
|
||||
|
||||
```typescript
|
||||
// 连接配置
|
||||
const MONGODB_URI = 'mongodb://admin:admin123@localhost:27017/?authSource=admin'
|
||||
|
||||
// 核心功能
|
||||
- getMongoClient() // 获取连接(连接池复用)
|
||||
- queryUserByPhone() // 按手机号查询
|
||||
- queryFullProfile() // 跨库完整画像
|
||||
- intelligentSearch() // 智能搜索
|
||||
- getDatabaseStats() // 数据库统计
|
||||
```
|
||||
|
||||
### 2. API 路由对接
|
||||
|
||||
| API | 功能 | 数据源 |
|
||||
|-----|------|--------|
|
||||
| `/api/users` | 用户列表查询 | KR.用户估值 |
|
||||
| `/api/search` | 智能搜索 | 多库联查 |
|
||||
| `/api/system-status` | 系统状态 | MongoDB 统计 |
|
||||
| `/api/rfm/group_summary` | RFM 分组 | KR.用户估值 |
|
||||
| `/api/rfm/analyze` | RFM 分析 | KR.用户估值 |
|
||||
|
||||
### 3. 数据库映射
|
||||
|
||||
| 前端字段 | MongoDB 字段 | 说明 |
|
||||
|----------|-------------|------|
|
||||
| `phone` | `phone` (+86前缀) | 支持多格式匹配 |
|
||||
| `name` | `name` | 用户姓名 |
|
||||
| `rfmScore` | `user_evaluation_score` | 估值分 |
|
||||
| `userLevel` | `user_level` | 用户等级 |
|
||||
| `province/city` | `province/city` | 地区 |
|
||||
|
||||
## 快速启动
|
||||
|
||||
```bash
|
||||
# 1. 确保 MongoDB Docker 运行中
|
||||
docker ps | grep mongodb
|
||||
|
||||
# 2. 安装依赖
|
||||
cd /Users/karuo/Documents/开发/2、私域银行/神射手
|
||||
pnpm install
|
||||
|
||||
# 3. 启动开发服务器
|
||||
pnpm dev
|
||||
# 访问 http://localhost:3001
|
||||
```
|
||||
|
||||
## 验证测试
|
||||
|
||||
```bash
|
||||
# 系统状态(应显示 20亿+ 记录)
|
||||
curl http://localhost:3001/api/system-status
|
||||
|
||||
# 搜索用户
|
||||
curl -X POST http://localhost:3001/api/search \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"query":"13407000001","type":"all"}'
|
||||
|
||||
# RFM 统计
|
||||
curl http://localhost:3001/api/rfm/group_summary
|
||||
```
|
||||
|
||||
## 数据库概览
|
||||
|
||||
| 数据库 | 集合数 | 记录数 | 大小 |
|
||||
|--------|--------|--------|------|
|
||||
| KR | 15 | 2926万 | 4.73 GB |
|
||||
| KR_腾讯 | 180 | 7.56亿 | 62.45 GB |
|
||||
| KR_微博 | 2 | 2.17亿 | 18.67 GB |
|
||||
| KR_京东 | 2 | 1.42亿 | 20.12 GB |
|
||||
| KR_酒店 | 5 | 2.31亿 | 36.36 GB |
|
||||
| **总计** | 400+ | **20亿+** | **207 GB** |
|
||||
|
||||
## 关键文件
|
||||
|
||||
```
|
||||
神射手/
|
||||
├── lib/
|
||||
│ └── mongodb.ts # MongoDB 连接器
|
||||
├── app/api/
|
||||
│ ├── users/route.ts # 用户 API
|
||||
│ ├── search/route.ts # 搜索 API
|
||||
│ ├── system-status/route.ts # 系统状态 API
|
||||
│ └── rfm/
|
||||
│ ├── analyze/route.ts # RFM 分析
|
||||
│ └── group_summary/route.ts # RFM 分组
|
||||
├── services/
|
||||
│ └── rfm-mongodb-service.ts # RFM MongoDB 服务
|
||||
└── 开发文档/
|
||||
└── v0-神射手集成指南.md # 本文档
|
||||
```
|
||||
|
||||
## 手机号格式处理
|
||||
|
||||
数据库中手机号格式为 `+8613800138000`,系统自动处理多种格式:
|
||||
|
||||
```typescript
|
||||
// 输入任意格式,自动匹配
|
||||
"13800138000" -> 匹配 +8613800138000
|
||||
"+8613800138000" -> 直接匹配
|
||||
"8613800138000" -> 匹配 +8613800138000
|
||||
```
|
||||
|
||||
## 后续开发建议
|
||||
|
||||
1. **添加 Redis 缓存** - 热数据缓存,提升响应速度
|
||||
2. **完善用户画像页面** - 显示完整跨库信息
|
||||
3. **添加 QQ/微博 查询** - 利用 KR_腾讯/KR_微博 数据
|
||||
4. **实现数据导出** - CSV/Excel 批量导出
|
||||
|
||||
---
|
||||
|
||||
*更新时间:2026-01-31*
|
||||
*版本:v1.0*
|
||||
BIN
开发文档/总览.png
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
开发文档/神射手白板-1.jpg
Normal file
|
After Width: | Height: | Size: 120 KiB |
BIN
开发文档/神射手白板-2.jpg
Normal file
|
After Width: | Height: | Size: 106 KiB |
BIN
开发文档/神射手白板-3.jpg
Normal file
|
After Width: | Height: | Size: 105 KiB |
BIN
开发文档/神射手白板-4.jpg
Normal file
|
After Width: | Height: | Size: 120 KiB |
BIN
开发文档/神射手白板-5.jpg
Normal file
|
After Width: | Height: | Size: 131 KiB |
445
开发文档/第一部分.html
Normal file
@@ -0,0 +1,445 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<title>神射手 · 开发文档|第一部分</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg0: #0b1020;
|
||||
--bg1: #111a33;
|
||||
--gls: rgba(255, 255, 255, 0.62);
|
||||
--gls2: rgba(255, 255, 255, 0.38);
|
||||
--txt: rgba(10, 14, 26, 0.92);
|
||||
--muted: rgba(10, 14, 26, 0.6);
|
||||
--bd: rgba(255, 255, 255, 0.38);
|
||||
--shadow: 0 18px 55px rgba(0, 0, 0, 0.22);
|
||||
--shadow2: 0 10px 28px rgba(0, 0, 0, 0.12);
|
||||
--radius: 18px;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--gls: rgba(14, 18, 32, 0.62);
|
||||
--gls2: rgba(14, 18, 32, 0.36);
|
||||
--txt: rgba(255, 255, 255, 0.92);
|
||||
--muted: rgba(255, 255, 255, 0.6);
|
||||
--bd: rgba(255, 255, 255, 0.14);
|
||||
--shadow: 0 22px 65px rgba(0, 0, 0, 0.55);
|
||||
--shadow2: 0 14px 35px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text",
|
||||
"PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", system-ui, sans-serif;
|
||||
color: var(--txt);
|
||||
background:
|
||||
radial-gradient(1200px 700px at 18% 12%, rgba(102, 162, 255, 0.42), transparent 55%),
|
||||
radial-gradient(900px 560px at 78% 22%, rgba(255, 176, 81, 0.34), transparent 52%),
|
||||
radial-gradient(900px 620px at 62% 86%, rgba(137, 255, 210, 0.20), transparent 55%),
|
||||
linear-gradient(180deg, var(--bg0), var(--bg1));
|
||||
background-attachment: fixed;
|
||||
}
|
||||
|
||||
.wrap {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 22px 18px 64px;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 30;
|
||||
padding: 12px 0 10px;
|
||||
backdrop-filter: blur(18px) saturate(165%);
|
||||
-webkit-backdrop-filter: blur(18px) saturate(165%);
|
||||
}
|
||||
|
||||
.topbar-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--bd);
|
||||
border-radius: var(--radius);
|
||||
background: var(--gls);
|
||||
box-shadow: var(--shadow2);
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.brand strong {
|
||||
font-size: 14px;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.brand span {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--bd);
|
||||
background: var(--gls2);
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
transition: transform 120ms ease, background 160ms ease;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.chip:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.chip[aria-current="page"] {
|
||||
background: linear-gradient(135deg, rgba(103, 140, 255, 0.35), rgba(255, 184, 103, 0.22));
|
||||
}
|
||||
|
||||
.hero {
|
||||
margin-top: 18px;
|
||||
padding: 18px 16px;
|
||||
border: 1px solid var(--bd);
|
||||
border-radius: calc(var(--radius) + 6px);
|
||||
background: var(--gls);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
|
||||
.hero p {
|
||||
margin: 8px 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.grid {
|
||||
margin-top: 14px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.card {
|
||||
border: 1px solid var(--bd);
|
||||
border-radius: calc(var(--radius) + 6px);
|
||||
background: var(--gls);
|
||||
box-shadow: var(--shadow);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-hd {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 14px 14px 10px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
|
||||
.card-hd h2 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.card-hd a {
|
||||
color: var(--muted);
|
||||
text-decoration: none;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.card-bd {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.imgbox {
|
||||
position: relative;
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 255, 255, 0.22);
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.imgbox img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.img-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--bd);
|
||||
background: var(--gls2);
|
||||
color: inherit;
|
||||
border-radius: 12px;
|
||||
padding: 9px 10px;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
transition: transform 120ms ease, background 160ms ease;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.footer {
|
||||
margin-top: 18px;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Modal */
|
||||
.modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 60;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 18px;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.modal.open {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.modal-inner {
|
||||
width: min(1180px, 100%);
|
||||
max-height: min(92vh, 980px);
|
||||
border-radius: 18px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
background: rgba(10, 12, 20, 0.62);
|
||||
box-shadow: 0 30px 90px rgba(0, 0, 0, 0.6);
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
}
|
||||
|
||||
.modal-hd {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.modal-hd .title {
|
||||
color: rgba(255, 255, 255, 0.82);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.modal-hd .close {
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
border-radius: 12px;
|
||||
padding: 8px 10px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.modal-bd {
|
||||
overflow: auto;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.modal-bd img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
@media (min-width: 860px) {
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<div class="topbar">
|
||||
<div class="topbar-inner">
|
||||
<div class="brand">
|
||||
<strong>神射手 · 开发文档</strong>
|
||||
<span>苹果毛玻璃风格|第一部分(上/下)</span>
|
||||
</div>
|
||||
<nav class="nav" aria-label="板块导航">
|
||||
<a class="chip" aria-current="page" href="./第一部分.html">第一部分</a>
|
||||
<a class="chip" href="./第二部分.html">第二部分</a>
|
||||
<a class="chip" href="./第三部分.html">第三部分</a>
|
||||
<a class="chip" href="./第四部分.html">第四部分</a>
|
||||
<a class="chip" href="./第五部分.html">第五部分</a>
|
||||
<a class="chip" href="#top">目录</a>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<header id="top" class="hero">
|
||||
<h1>第一部分</h1>
|
||||
<p>
|
||||
说明:本页把 <strong>第一部分上/下</strong> 两张原始页面截图整理成统一风格的文档页。点击图片可放大预览;也可直接下载原图。
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<main class="grid">
|
||||
<section id="p1-up" class="card">
|
||||
<div class="card-hd">
|
||||
<h2>第一部分(上)</h2>
|
||||
<a href="#top">回到目录</a>
|
||||
</div>
|
||||
<div class="card-bd">
|
||||
<div class="imgbox">
|
||||
<img
|
||||
src="./第一部分上.png"
|
||||
alt="第一部分上"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
data-preview-title="第一部分(上)"
|
||||
/>
|
||||
</div>
|
||||
<div class="img-actions">
|
||||
<button class="btn" type="button" data-preview="./第一部分上.png">放大预览</button>
|
||||
<a class="btn" href="./第一部分上.png" download>下载原图</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="p1-down" class="card">
|
||||
<div class="card-hd">
|
||||
<h2>第一部分(下)</h2>
|
||||
<a href="#top">回到目录</a>
|
||||
</div>
|
||||
<div class="card-bd">
|
||||
<div class="imgbox">
|
||||
<img
|
||||
src="./第一部分下.png"
|
||||
alt="第一部分下"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
data-preview-title="第一部分(下)"
|
||||
/>
|
||||
</div>
|
||||
<div class="img-actions">
|
||||
<button class="btn" type="button" data-preview="./第一部分下.png">放大预览</button>
|
||||
<a class="btn" href="./第一部分下.png" download>下载原图</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div class="footer">更新时间:2026-01-04 · 本地静态 HTML(无需服务即可打开)</div>
|
||||
</div>
|
||||
|
||||
<div class="modal" id="modal" role="dialog" aria-modal="true" aria-hidden="true">
|
||||
<div class="modal-inner">
|
||||
<div class="modal-hd">
|
||||
<div class="title" id="modalTitle">预览</div>
|
||||
<button class="close" type="button" id="modalClose">关闭(Esc)</button>
|
||||
</div>
|
||||
<div class="modal-bd">
|
||||
<img id="modalImg" src="" alt="预览图片" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
const modal = document.getElementById("modal");
|
||||
const modalImg = document.getElementById("modalImg");
|
||||
const modalTitle = document.getElementById("modalTitle");
|
||||
const closeBtn = document.getElementById("modalClose");
|
||||
|
||||
function openModal(src, title) {
|
||||
modalImg.src = src;
|
||||
modalTitle.textContent = title || "预览";
|
||||
modal.classList.add("open");
|
||||
modal.setAttribute("aria-hidden", "false");
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
modal.classList.remove("open");
|
||||
modal.setAttribute("aria-hidden", "true");
|
||||
modalImg.src = "";
|
||||
}
|
||||
|
||||
document.addEventListener("click", (e) => {
|
||||
const btn = e.target.closest("[data-preview]");
|
||||
if (btn) {
|
||||
const src = btn.getAttribute("data-preview");
|
||||
const img = document.querySelector(`img[src="${src}"]`);
|
||||
const title = (img && img.getAttribute("data-preview-title")) || "预览";
|
||||
openModal(src, title);
|
||||
return;
|
||||
}
|
||||
|
||||
const img = e.target.closest("img[data-preview-title]");
|
||||
if (img && img.src) {
|
||||
openModal(img.getAttribute("src"), img.getAttribute("data-preview-title"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.target === modal) closeModal();
|
||||
});
|
||||
|
||||
closeBtn.addEventListener("click", closeModal);
|
||||
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape") closeModal();
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
BIN
开发文档/第一部分上.png
Normal file
|
After Width: | Height: | Size: 4.5 MiB |
BIN
开发文档/第一部分下.png
Normal file
|
After Width: | Height: | Size: 5.5 MiB |
438
开发文档/第三部分.html
Normal file
@@ -0,0 +1,438 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<title>神射手 · 开发文档|第三部分</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg0: #0b1020;
|
||||
--bg1: #111a33;
|
||||
--gls: rgba(255, 255, 255, 0.62);
|
||||
--gls2: rgba(255, 255, 255, 0.38);
|
||||
--txt: rgba(10, 14, 26, 0.92);
|
||||
--muted: rgba(10, 14, 26, 0.6);
|
||||
--bd: rgba(255, 255, 255, 0.38);
|
||||
--shadow: 0 18px 55px rgba(0, 0, 0, 0.22);
|
||||
--shadow2: 0 10px 28px rgba(0, 0, 0, 0.12);
|
||||
--radius: 18px;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--gls: rgba(14, 18, 32, 0.62);
|
||||
--gls2: rgba(14, 18, 32, 0.36);
|
||||
--txt: rgba(255, 255, 255, 0.92);
|
||||
--muted: rgba(255, 255, 255, 0.6);
|
||||
--bd: rgba(255, 255, 255, 0.14);
|
||||
--shadow: 0 22px 65px rgba(0, 0, 0, 0.55);
|
||||
--shadow2: 0 14px 35px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text",
|
||||
"PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", system-ui, sans-serif;
|
||||
color: var(--txt);
|
||||
background:
|
||||
radial-gradient(1200px 700px at 18% 12%, rgba(102, 162, 255, 0.42), transparent 55%),
|
||||
radial-gradient(900px 560px at 78% 22%, rgba(255, 176, 81, 0.34), transparent 52%),
|
||||
radial-gradient(900px 620px at 62% 86%, rgba(137, 255, 210, 0.20), transparent 55%),
|
||||
linear-gradient(180deg, var(--bg0), var(--bg1));
|
||||
background-attachment: fixed;
|
||||
}
|
||||
|
||||
.wrap {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 22px 18px 64px;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 30;
|
||||
padding: 12px 0 10px;
|
||||
backdrop-filter: blur(18px) saturate(165%);
|
||||
-webkit-backdrop-filter: blur(18px) saturate(165%);
|
||||
}
|
||||
|
||||
.topbar-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--bd);
|
||||
border-radius: var(--radius);
|
||||
background: var(--gls);
|
||||
box-shadow: var(--shadow2);
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.brand strong {
|
||||
font-size: 14px;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.brand span {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--bd);
|
||||
background: var(--gls2);
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
transition: transform 120ms ease, background 160ms ease;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.chip:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.chip[aria-current="page"] {
|
||||
background: linear-gradient(135deg, rgba(103, 140, 255, 0.35), rgba(255, 184, 103, 0.22));
|
||||
}
|
||||
|
||||
.hero {
|
||||
margin-top: 18px;
|
||||
padding: 18px 16px;
|
||||
border: 1px solid var(--bd);
|
||||
border-radius: calc(var(--radius) + 6px);
|
||||
background: var(--gls);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
|
||||
.hero p {
|
||||
margin: 8px 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.grid {
|
||||
margin-top: 14px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.card {
|
||||
border: 1px solid var(--bd);
|
||||
border-radius: calc(var(--radius) + 6px);
|
||||
background: var(--gls);
|
||||
box-shadow: var(--shadow);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-hd {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 14px 14px 10px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
|
||||
.card-hd h2 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.card-hd a {
|
||||
color: var(--muted);
|
||||
text-decoration: none;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.card-bd {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.imgbox {
|
||||
position: relative;
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 255, 255, 0.22);
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.imgbox img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.img-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--bd);
|
||||
background: var(--gls2);
|
||||
color: inherit;
|
||||
border-radius: 12px;
|
||||
padding: 9px 10px;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
transition: transform 120ms ease, background 160ms ease;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.footer {
|
||||
margin-top: 18px;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 60;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 18px;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.modal.open {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.modal-inner {
|
||||
width: min(1180px, 100%);
|
||||
max-height: min(92vh, 980px);
|
||||
border-radius: 18px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
background: rgba(10, 12, 20, 0.62);
|
||||
box-shadow: 0 30px 90px rgba(0, 0, 0, 0.6);
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
}
|
||||
|
||||
.modal-hd {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.modal-hd .title {
|
||||
color: rgba(255, 255, 255, 0.82);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.modal-hd .close {
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
border-radius: 12px;
|
||||
padding: 8px 10px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.modal-bd {
|
||||
overflow: auto;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.modal-bd img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
border-radius: 14px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<div class="topbar">
|
||||
<div class="topbar-inner">
|
||||
<div class="brand">
|
||||
<strong>神射手 · 开发文档</strong>
|
||||
<span>苹果毛玻璃风格|第三部分(上/下)</span>
|
||||
</div>
|
||||
<nav class="nav" aria-label="板块导航">
|
||||
<a class="chip" href="./第一部分.html">第一部分</a>
|
||||
<a class="chip" href="./第二部分.html">第二部分</a>
|
||||
<a class="chip" aria-current="page" href="./第三部分.html">第三部分</a>
|
||||
<a class="chip" href="./第四部分.html">第四部分</a>
|
||||
<a class="chip" href="./第五部分.html">第五部分</a>
|
||||
<a class="chip" href="#top">目录</a>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<header id="top" class="hero">
|
||||
<h1>第三部分</h1>
|
||||
<p>
|
||||
说明:本页把 <strong>第三部分上/下</strong> 两张原始页面截图整理成统一风格的文档页。点击图片可放大预览;也可直接下载原图。
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<main class="grid">
|
||||
<section id="p3-up" class="card">
|
||||
<div class="card-hd">
|
||||
<h2>第三部分(上)</h2>
|
||||
<a href="#top">回到目录</a>
|
||||
</div>
|
||||
<div class="card-bd">
|
||||
<div class="imgbox">
|
||||
<img
|
||||
src="./第三部分上.png"
|
||||
alt="第三部分上"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
data-preview-title="第三部分(上)"
|
||||
/>
|
||||
</div>
|
||||
<div class="img-actions">
|
||||
<button class="btn" type="button" data-preview="./第三部分上.png">放大预览</button>
|
||||
<a class="btn" href="./第三部分上.png" download>下载原图</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="p3-down" class="card">
|
||||
<div class="card-hd">
|
||||
<h2>第三部分(下)</h2>
|
||||
<a href="#top">回到目录</a>
|
||||
</div>
|
||||
<div class="card-bd">
|
||||
<div class="imgbox">
|
||||
<img
|
||||
src="./第三部分下.png"
|
||||
alt="第三部分下"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
data-preview-title="第三部分(下)"
|
||||
/>
|
||||
</div>
|
||||
<div class="img-actions">
|
||||
<button class="btn" type="button" data-preview="./第三部分下.png">放大预览</button>
|
||||
<a class="btn" href="./第三部分下.png" download>下载原图</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div class="footer">更新时间:2026-01-04 · 本地静态 HTML(无需服务即可打开)</div>
|
||||
</div>
|
||||
|
||||
<div class="modal" id="modal" role="dialog" aria-modal="true" aria-hidden="true">
|
||||
<div class="modal-inner">
|
||||
<div class="modal-hd">
|
||||
<div class="title" id="modalTitle">预览</div>
|
||||
<button class="close" type="button" id="modalClose">关闭(Esc)</button>
|
||||
</div>
|
||||
<div class="modal-bd">
|
||||
<img id="modalImg" src="" alt="预览图片" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
const modal = document.getElementById("modal");
|
||||
const modalImg = document.getElementById("modalImg");
|
||||
const modalTitle = document.getElementById("modalTitle");
|
||||
const closeBtn = document.getElementById("modalClose");
|
||||
|
||||
function openModal(src, title) {
|
||||
modalImg.src = src;
|
||||
modalTitle.textContent = title || "预览";
|
||||
modal.classList.add("open");
|
||||
modal.setAttribute("aria-hidden", "false");
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
modal.classList.remove("open");
|
||||
modal.setAttribute("aria-hidden", "true");
|
||||
modalImg.src = "";
|
||||
}
|
||||
|
||||
document.addEventListener("click", (e) => {
|
||||
const btn = e.target.closest("[data-preview]");
|
||||
if (btn) {
|
||||
const src = btn.getAttribute("data-preview");
|
||||
const img = document.querySelector(`img[src="${src}"]`);
|
||||
const title = (img && img.getAttribute("data-preview-title")) || "预览";
|
||||
openModal(src, title);
|
||||
return;
|
||||
}
|
||||
|
||||
const img = e.target.closest("img[data-preview-title]");
|
||||
if (img && img.src) {
|
||||
openModal(img.getAttribute("src"), img.getAttribute("data-preview-title"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.target === modal) closeModal();
|
||||
});
|
||||
|
||||
closeBtn.addEventListener("click", closeModal);
|
||||
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape") closeModal();
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
BIN
开发文档/第三部分上.png
Normal file
|
After Width: | Height: | Size: 2.2 MiB |
BIN
开发文档/第三部分下.png
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
438
开发文档/第二部分.html
Normal file
@@ -0,0 +1,438 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<title>神射手 · 开发文档|第二部分</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg0: #0b1020;
|
||||
--bg1: #111a33;
|
||||
--gls: rgba(255, 255, 255, 0.62);
|
||||
--gls2: rgba(255, 255, 255, 0.38);
|
||||
--txt: rgba(10, 14, 26, 0.92);
|
||||
--muted: rgba(10, 14, 26, 0.6);
|
||||
--bd: rgba(255, 255, 255, 0.38);
|
||||
--shadow: 0 18px 55px rgba(0, 0, 0, 0.22);
|
||||
--shadow2: 0 10px 28px rgba(0, 0, 0, 0.12);
|
||||
--radius: 18px;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--gls: rgba(14, 18, 32, 0.62);
|
||||
--gls2: rgba(14, 18, 32, 0.36);
|
||||
--txt: rgba(255, 255, 255, 0.92);
|
||||
--muted: rgba(255, 255, 255, 0.6);
|
||||
--bd: rgba(255, 255, 255, 0.14);
|
||||
--shadow: 0 22px 65px rgba(0, 0, 0, 0.55);
|
||||
--shadow2: 0 14px 35px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text",
|
||||
"PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", system-ui, sans-serif;
|
||||
color: var(--txt);
|
||||
background:
|
||||
radial-gradient(1200px 700px at 18% 12%, rgba(102, 162, 255, 0.42), transparent 55%),
|
||||
radial-gradient(900px 560px at 78% 22%, rgba(255, 176, 81, 0.34), transparent 52%),
|
||||
radial-gradient(900px 620px at 62% 86%, rgba(137, 255, 210, 0.20), transparent 55%),
|
||||
linear-gradient(180deg, var(--bg0), var(--bg1));
|
||||
background-attachment: fixed;
|
||||
}
|
||||
|
||||
.wrap {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 22px 18px 64px;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 30;
|
||||
padding: 12px 0 10px;
|
||||
backdrop-filter: blur(18px) saturate(165%);
|
||||
-webkit-backdrop-filter: blur(18px) saturate(165%);
|
||||
}
|
||||
|
||||
.topbar-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--bd);
|
||||
border-radius: var(--radius);
|
||||
background: var(--gls);
|
||||
box-shadow: var(--shadow2);
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.brand strong {
|
||||
font-size: 14px;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.brand span {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--bd);
|
||||
background: var(--gls2);
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
transition: transform 120ms ease, background 160ms ease;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.chip:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.chip[aria-current="page"] {
|
||||
background: linear-gradient(135deg, rgba(103, 140, 255, 0.35), rgba(255, 184, 103, 0.22));
|
||||
}
|
||||
|
||||
.hero {
|
||||
margin-top: 18px;
|
||||
padding: 18px 16px;
|
||||
border: 1px solid var(--bd);
|
||||
border-radius: calc(var(--radius) + 6px);
|
||||
background: var(--gls);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
|
||||
.hero p {
|
||||
margin: 8px 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.grid {
|
||||
margin-top: 14px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.card {
|
||||
border: 1px solid var(--bd);
|
||||
border-radius: calc(var(--radius) + 6px);
|
||||
background: var(--gls);
|
||||
box-shadow: var(--shadow);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-hd {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 14px 14px 10px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
|
||||
.card-hd h2 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.card-hd a {
|
||||
color: var(--muted);
|
||||
text-decoration: none;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.card-bd {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.imgbox {
|
||||
position: relative;
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 255, 255, 0.22);
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.imgbox img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.img-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--bd);
|
||||
background: var(--gls2);
|
||||
color: inherit;
|
||||
border-radius: 12px;
|
||||
padding: 9px 10px;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
transition: transform 120ms ease, background 160ms ease;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.footer {
|
||||
margin-top: 18px;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 60;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 18px;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.modal.open {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.modal-inner {
|
||||
width: min(1180px, 100%);
|
||||
max-height: min(92vh, 980px);
|
||||
border-radius: 18px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
background: rgba(10, 12, 20, 0.62);
|
||||
box-shadow: 0 30px 90px rgba(0, 0, 0, 0.6);
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
}
|
||||
|
||||
.modal-hd {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.modal-hd .title {
|
||||
color: rgba(255, 255, 255, 0.82);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.modal-hd .close {
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
border-radius: 12px;
|
||||
padding: 8px 10px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.modal-bd {
|
||||
overflow: auto;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.modal-bd img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
border-radius: 14px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<div class="topbar">
|
||||
<div class="topbar-inner">
|
||||
<div class="brand">
|
||||
<strong>神射手 · 开发文档</strong>
|
||||
<span>苹果毛玻璃风格|第二部分(上/下)</span>
|
||||
</div>
|
||||
<nav class="nav" aria-label="板块导航">
|
||||
<a class="chip" href="./第一部分.html">第一部分</a>
|
||||
<a class="chip" aria-current="page" href="./第二部分.html">第二部分</a>
|
||||
<a class="chip" href="./第三部分.html">第三部分</a>
|
||||
<a class="chip" href="./第四部分.html">第四部分</a>
|
||||
<a class="chip" href="./第五部分.html">第五部分</a>
|
||||
<a class="chip" href="#top">目录</a>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<header id="top" class="hero">
|
||||
<h1>第二部分</h1>
|
||||
<p>
|
||||
说明:本页把 <strong>第二部分上/下</strong> 两张原始页面截图整理成统一风格的文档页。点击图片可放大预览;也可直接下载原图。
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<main class="grid">
|
||||
<section id="p2-up" class="card">
|
||||
<div class="card-hd">
|
||||
<h2>第二部分(上)</h2>
|
||||
<a href="#top">回到目录</a>
|
||||
</div>
|
||||
<div class="card-bd">
|
||||
<div class="imgbox">
|
||||
<img
|
||||
src="./第二部分上.png"
|
||||
alt="第二部分上"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
data-preview-title="第二部分(上)"
|
||||
/>
|
||||
</div>
|
||||
<div class="img-actions">
|
||||
<button class="btn" type="button" data-preview="./第二部分上.png">放大预览</button>
|
||||
<a class="btn" href="./第二部分上.png" download>下载原图</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="p2-down" class="card">
|
||||
<div class="card-hd">
|
||||
<h2>第二部分(下)</h2>
|
||||
<a href="#top">回到目录</a>
|
||||
</div>
|
||||
<div class="card-bd">
|
||||
<div class="imgbox">
|
||||
<img
|
||||
src="./第二部分下.png"
|
||||
alt="第二部分下"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
data-preview-title="第二部分(下)"
|
||||
/>
|
||||
</div>
|
||||
<div class="img-actions">
|
||||
<button class="btn" type="button" data-preview="./第二部分下.png">放大预览</button>
|
||||
<a class="btn" href="./第二部分下.png" download>下载原图</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div class="footer">更新时间:2026-01-04 · 本地静态 HTML(无需服务即可打开)</div>
|
||||
</div>
|
||||
|
||||
<div class="modal" id="modal" role="dialog" aria-modal="true" aria-hidden="true">
|
||||
<div class="modal-inner">
|
||||
<div class="modal-hd">
|
||||
<div class="title" id="modalTitle">预览</div>
|
||||
<button class="close" type="button" id="modalClose">关闭(Esc)</button>
|
||||
</div>
|
||||
<div class="modal-bd">
|
||||
<img id="modalImg" src="" alt="预览图片" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
const modal = document.getElementById("modal");
|
||||
const modalImg = document.getElementById("modalImg");
|
||||
const modalTitle = document.getElementById("modalTitle");
|
||||
const closeBtn = document.getElementById("modalClose");
|
||||
|
||||
function openModal(src, title) {
|
||||
modalImg.src = src;
|
||||
modalTitle.textContent = title || "预览";
|
||||
modal.classList.add("open");
|
||||
modal.setAttribute("aria-hidden", "false");
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
modal.classList.remove("open");
|
||||
modal.setAttribute("aria-hidden", "true");
|
||||
modalImg.src = "";
|
||||
}
|
||||
|
||||
document.addEventListener("click", (e) => {
|
||||
const btn = e.target.closest("[data-preview]");
|
||||
if (btn) {
|
||||
const src = btn.getAttribute("data-preview");
|
||||
const img = document.querySelector(`img[src="${src}"]`);
|
||||
const title = (img && img.getAttribute("data-preview-title")) || "预览";
|
||||
openModal(src, title);
|
||||
return;
|
||||
}
|
||||
|
||||
const img = e.target.closest("img[data-preview-title]");
|
||||
if (img && img.src) {
|
||||
openModal(img.getAttribute("src"), img.getAttribute("data-preview-title"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.target === modal) closeModal();
|
||||
});
|
||||
|
||||
closeBtn.addEventListener("click", closeModal);
|
||||
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape") closeModal();
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
BIN
开发文档/第二部分上.png
Normal file
|
After Width: | Height: | Size: 2.3 MiB |
BIN
开发文档/第二部分下.png
Normal file
|
After Width: | Height: | Size: 2.6 MiB |
438
开发文档/第五部分.html
Normal file
@@ -0,0 +1,438 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<title>神射手 · 开发文档|第五部分</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg0: #0b1020;
|
||||
--bg1: #111a33;
|
||||
--gls: rgba(255, 255, 255, 0.62);
|
||||
--gls2: rgba(255, 255, 255, 0.38);
|
||||
--txt: rgba(10, 14, 26, 0.92);
|
||||
--muted: rgba(10, 14, 26, 0.6);
|
||||
--bd: rgba(255, 255, 255, 0.38);
|
||||
--shadow: 0 18px 55px rgba(0, 0, 0, 0.22);
|
||||
--shadow2: 0 10px 28px rgba(0, 0, 0, 0.12);
|
||||
--radius: 18px;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--gls: rgba(14, 18, 32, 0.62);
|
||||
--gls2: rgba(14, 18, 32, 0.36);
|
||||
--txt: rgba(255, 255, 255, 0.92);
|
||||
--muted: rgba(255, 255, 255, 0.6);
|
||||
--bd: rgba(255, 255, 255, 0.14);
|
||||
--shadow: 0 22px 65px rgba(0, 0, 0, 0.55);
|
||||
--shadow2: 0 14px 35px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text",
|
||||
"PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", system-ui, sans-serif;
|
||||
color: var(--txt);
|
||||
background:
|
||||
radial-gradient(1200px 700px at 18% 12%, rgba(102, 162, 255, 0.42), transparent 55%),
|
||||
radial-gradient(900px 560px at 78% 22%, rgba(255, 176, 81, 0.34), transparent 52%),
|
||||
radial-gradient(900px 620px at 62% 86%, rgba(137, 255, 210, 0.20), transparent 55%),
|
||||
linear-gradient(180deg, var(--bg0), var(--bg1));
|
||||
background-attachment: fixed;
|
||||
}
|
||||
|
||||
.wrap {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 22px 18px 64px;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 30;
|
||||
padding: 12px 0 10px;
|
||||
backdrop-filter: blur(18px) saturate(165%);
|
||||
-webkit-backdrop-filter: blur(18px) saturate(165%);
|
||||
}
|
||||
|
||||
.topbar-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--bd);
|
||||
border-radius: var(--radius);
|
||||
background: var(--gls);
|
||||
box-shadow: var(--shadow2);
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.brand strong {
|
||||
font-size: 14px;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.brand span {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--bd);
|
||||
background: var(--gls2);
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
transition: transform 120ms ease, background 160ms ease;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.chip:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.chip[aria-current="page"] {
|
||||
background: linear-gradient(135deg, rgba(103, 140, 255, 0.35), rgba(255, 184, 103, 0.22));
|
||||
}
|
||||
|
||||
.hero {
|
||||
margin-top: 18px;
|
||||
padding: 18px 16px;
|
||||
border: 1px solid var(--bd);
|
||||
border-radius: calc(var(--radius) + 6px);
|
||||
background: var(--gls);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
|
||||
.hero p {
|
||||
margin: 8px 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.grid {
|
||||
margin-top: 14px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.card {
|
||||
border: 1px solid var(--bd);
|
||||
border-radius: calc(var(--radius) + 6px);
|
||||
background: var(--gls);
|
||||
box-shadow: var(--shadow);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-hd {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 14px 14px 10px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
|
||||
.card-hd h2 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.card-hd a {
|
||||
color: var(--muted);
|
||||
text-decoration: none;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.card-bd {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.imgbox {
|
||||
position: relative;
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 255, 255, 0.22);
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.imgbox img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.img-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--bd);
|
||||
background: var(--gls2);
|
||||
color: inherit;
|
||||
border-radius: 12px;
|
||||
padding: 9px 10px;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
transition: transform 120ms ease, background 160ms ease;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.footer {
|
||||
margin-top: 18px;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 60;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 18px;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.modal.open {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.modal-inner {
|
||||
width: min(1180px, 100%);
|
||||
max-height: min(92vh, 980px);
|
||||
border-radius: 18px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
background: rgba(10, 12, 20, 0.62);
|
||||
box-shadow: 0 30px 90px rgba(0, 0, 0, 0.6);
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
}
|
||||
|
||||
.modal-hd {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.modal-hd .title {
|
||||
color: rgba(255, 255, 255, 0.82);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.modal-hd .close {
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
border-radius: 12px;
|
||||
padding: 8px 10px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.modal-bd {
|
||||
overflow: auto;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.modal-bd img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
border-radius: 14px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<div class="topbar">
|
||||
<div class="topbar-inner">
|
||||
<div class="brand">
|
||||
<strong>神射手 · 开发文档</strong>
|
||||
<span>苹果毛玻璃风格|第五部分(上/下)</span>
|
||||
</div>
|
||||
<nav class="nav" aria-label="板块导航">
|
||||
<a class="chip" href="./第一部分.html">第一部分</a>
|
||||
<a class="chip" href="./第二部分.html">第二部分</a>
|
||||
<a class="chip" href="./第三部分.html">第三部分</a>
|
||||
<a class="chip" href="./第四部分.html">第四部分</a>
|
||||
<a class="chip" aria-current="page" href="./第五部分.html">第五部分</a>
|
||||
<a class="chip" href="#top">目录</a>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<header id="top" class="hero">
|
||||
<h1>第五部分</h1>
|
||||
<p>
|
||||
说明:本页把 <strong>第五部分上/下</strong> 两张原始页面截图整理成统一风格的文档页。点击图片可放大预览;也可直接下载原图。
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<main class="grid">
|
||||
<section id="p5-up" class="card">
|
||||
<div class="card-hd">
|
||||
<h2>第五部分(上)</h2>
|
||||
<a href="#top">回到目录</a>
|
||||
</div>
|
||||
<div class="card-bd">
|
||||
<div class="imgbox">
|
||||
<img
|
||||
src="./第五部分上.png"
|
||||
alt="第五部分上"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
data-preview-title="第五部分(上)"
|
||||
/>
|
||||
</div>
|
||||
<div class="img-actions">
|
||||
<button class="btn" type="button" data-preview="./第五部分上.png">放大预览</button>
|
||||
<a class="btn" href="./第五部分上.png" download>下载原图</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="p5-down" class="card">
|
||||
<div class="card-hd">
|
||||
<h2>第五部分(下)</h2>
|
||||
<a href="#top">回到目录</a>
|
||||
</div>
|
||||
<div class="card-bd">
|
||||
<div class="imgbox">
|
||||
<img
|
||||
src="./第五部分下.png"
|
||||
alt="第五部分下"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
data-preview-title="第五部分(下)"
|
||||
/>
|
||||
</div>
|
||||
<div class="img-actions">
|
||||
<button class="btn" type="button" data-preview="./第五部分下.png">放大预览</button>
|
||||
<a class="btn" href="./第五部分下.png" download>下载原图</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div class="footer">更新时间:2026-01-04 · 本地静态 HTML(无需服务即可打开)</div>
|
||||
</div>
|
||||
|
||||
<div class="modal" id="modal" role="dialog" aria-modal="true" aria-hidden="true">
|
||||
<div class="modal-inner">
|
||||
<div class="modal-hd">
|
||||
<div class="title" id="modalTitle">预览</div>
|
||||
<button class="close" type="button" id="modalClose">关闭(Esc)</button>
|
||||
</div>
|
||||
<div class="modal-bd">
|
||||
<img id="modalImg" src="" alt="预览图片" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
const modal = document.getElementById("modal");
|
||||
const modalImg = document.getElementById("modalImg");
|
||||
const modalTitle = document.getElementById("modalTitle");
|
||||
const closeBtn = document.getElementById("modalClose");
|
||||
|
||||
function openModal(src, title) {
|
||||
modalImg.src = src;
|
||||
modalTitle.textContent = title || "预览";
|
||||
modal.classList.add("open");
|
||||
modal.setAttribute("aria-hidden", "false");
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
modal.classList.remove("open");
|
||||
modal.setAttribute("aria-hidden", "true");
|
||||
modalImg.src = "";
|
||||
}
|
||||
|
||||
document.addEventListener("click", (e) => {
|
||||
const btn = e.target.closest("[data-preview]");
|
||||
if (btn) {
|
||||
const src = btn.getAttribute("data-preview");
|
||||
const img = document.querySelector(`img[src="${src}"]`);
|
||||
const title = (img && img.getAttribute("data-preview-title")) || "预览";
|
||||
openModal(src, title);
|
||||
return;
|
||||
}
|
||||
|
||||
const img = e.target.closest("img[data-preview-title]");
|
||||
if (img && img.src) {
|
||||
openModal(img.getAttribute("src"), img.getAttribute("data-preview-title"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.target === modal) closeModal();
|
||||
});
|
||||
|
||||
closeBtn.addEventListener("click", closeModal);
|
||||
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape") closeModal();
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
BIN
开发文档/第五部分上.png
Normal file
|
After Width: | Height: | Size: 3.0 MiB |
BIN
开发文档/第五部分下.png
Normal file
|
After Width: | Height: | Size: 2.7 MiB |
438
开发文档/第四部分.html
Normal file
@@ -0,0 +1,438 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<title>神射手 · 开发文档|第四部分</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg0: #0b1020;
|
||||
--bg1: #111a33;
|
||||
--gls: rgba(255, 255, 255, 0.62);
|
||||
--gls2: rgba(255, 255, 255, 0.38);
|
||||
--txt: rgba(10, 14, 26, 0.92);
|
||||
--muted: rgba(10, 14, 26, 0.6);
|
||||
--bd: rgba(255, 255, 255, 0.38);
|
||||
--shadow: 0 18px 55px rgba(0, 0, 0, 0.22);
|
||||
--shadow2: 0 10px 28px rgba(0, 0, 0, 0.12);
|
||||
--radius: 18px;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--gls: rgba(14, 18, 32, 0.62);
|
||||
--gls2: rgba(14, 18, 32, 0.36);
|
||||
--txt: rgba(255, 255, 255, 0.92);
|
||||
--muted: rgba(255, 255, 255, 0.6);
|
||||
--bd: rgba(255, 255, 255, 0.14);
|
||||
--shadow: 0 22px 65px rgba(0, 0, 0, 0.55);
|
||||
--shadow2: 0 14px 35px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text",
|
||||
"PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", system-ui, sans-serif;
|
||||
color: var(--txt);
|
||||
background:
|
||||
radial-gradient(1200px 700px at 18% 12%, rgba(102, 162, 255, 0.42), transparent 55%),
|
||||
radial-gradient(900px 560px at 78% 22%, rgba(255, 176, 81, 0.34), transparent 52%),
|
||||
radial-gradient(900px 620px at 62% 86%, rgba(137, 255, 210, 0.20), transparent 55%),
|
||||
linear-gradient(180deg, var(--bg0), var(--bg1));
|
||||
background-attachment: fixed;
|
||||
}
|
||||
|
||||
.wrap {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 22px 18px 64px;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 30;
|
||||
padding: 12px 0 10px;
|
||||
backdrop-filter: blur(18px) saturate(165%);
|
||||
-webkit-backdrop-filter: blur(18px) saturate(165%);
|
||||
}
|
||||
|
||||
.topbar-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--bd);
|
||||
border-radius: var(--radius);
|
||||
background: var(--gls);
|
||||
box-shadow: var(--shadow2);
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.brand strong {
|
||||
font-size: 14px;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.brand span {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--bd);
|
||||
background: var(--gls2);
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
transition: transform 120ms ease, background 160ms ease;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.chip:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.chip[aria-current="page"] {
|
||||
background: linear-gradient(135deg, rgba(103, 140, 255, 0.35), rgba(255, 184, 103, 0.22));
|
||||
}
|
||||
|
||||
.hero {
|
||||
margin-top: 18px;
|
||||
padding: 18px 16px;
|
||||
border: 1px solid var(--bd);
|
||||
border-radius: calc(var(--radius) + 6px);
|
||||
background: var(--gls);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
|
||||
.hero p {
|
||||
margin: 8px 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.grid {
|
||||
margin-top: 14px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.card {
|
||||
border: 1px solid var(--bd);
|
||||
border-radius: calc(var(--radius) + 6px);
|
||||
background: var(--gls);
|
||||
box-shadow: var(--shadow);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-hd {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 14px 14px 10px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
|
||||
.card-hd h2 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.card-hd a {
|
||||
color: var(--muted);
|
||||
text-decoration: none;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.card-bd {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.imgbox {
|
||||
position: relative;
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 255, 255, 0.22);
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.imgbox img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.img-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--bd);
|
||||
background: var(--gls2);
|
||||
color: inherit;
|
||||
border-radius: 12px;
|
||||
padding: 9px 10px;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
transition: transform 120ms ease, background 160ms ease;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.footer {
|
||||
margin-top: 18px;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 60;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 18px;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.modal.open {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.modal-inner {
|
||||
width: min(1180px, 100%);
|
||||
max-height: min(92vh, 980px);
|
||||
border-radius: 18px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
background: rgba(10, 12, 20, 0.62);
|
||||
box-shadow: 0 30px 90px rgba(0, 0, 0, 0.6);
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
}
|
||||
|
||||
.modal-hd {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.modal-hd .title {
|
||||
color: rgba(255, 255, 255, 0.82);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.modal-hd .close {
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
border-radius: 12px;
|
||||
padding: 8px 10px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.modal-bd {
|
||||
overflow: auto;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.modal-bd img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
border-radius: 14px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<div class="topbar">
|
||||
<div class="topbar-inner">
|
||||
<div class="brand">
|
||||
<strong>神射手 · 开发文档</strong>
|
||||
<span>苹果毛玻璃风格|第四部分(上/下)</span>
|
||||
</div>
|
||||
<nav class="nav" aria-label="板块导航">
|
||||
<a class="chip" href="./第一部分.html">第一部分</a>
|
||||
<a class="chip" href="./第二部分.html">第二部分</a>
|
||||
<a class="chip" href="./第三部分.html">第三部分</a>
|
||||
<a class="chip" aria-current="page" href="./第四部分.html">第四部分</a>
|
||||
<a class="chip" href="./第五部分.html">第五部分</a>
|
||||
<a class="chip" href="#top">目录</a>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<header id="top" class="hero">
|
||||
<h1>第四部分</h1>
|
||||
<p>
|
||||
说明:本页把 <strong>第四部分上/下</strong> 两张原始页面截图整理成统一风格的文档页。点击图片可放大预览;也可直接下载原图。
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<main class="grid">
|
||||
<section id="p4-up" class="card">
|
||||
<div class="card-hd">
|
||||
<h2>第四部分(上)</h2>
|
||||
<a href="#top">回到目录</a>
|
||||
</div>
|
||||
<div class="card-bd">
|
||||
<div class="imgbox">
|
||||
<img
|
||||
src="./第四部分上.png"
|
||||
alt="第四部分上"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
data-preview-title="第四部分(上)"
|
||||
/>
|
||||
</div>
|
||||
<div class="img-actions">
|
||||
<button class="btn" type="button" data-preview="./第四部分上.png">放大预览</button>
|
||||
<a class="btn" href="./第四部分上.png" download>下载原图</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="p4-down" class="card">
|
||||
<div class="card-hd">
|
||||
<h2>第四部分(下)</h2>
|
||||
<a href="#top">回到目录</a>
|
||||
</div>
|
||||
<div class="card-bd">
|
||||
<div class="imgbox">
|
||||
<img
|
||||
src="./第四部分下.png"
|
||||
alt="第四部分下"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
data-preview-title="第四部分(下)"
|
||||
/>
|
||||
</div>
|
||||
<div class="img-actions">
|
||||
<button class="btn" type="button" data-preview="./第四部分下.png">放大预览</button>
|
||||
<a class="btn" href="./第四部分下.png" download>下载原图</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div class="footer">更新时间:2026-01-04 · 本地静态 HTML(无需服务即可打开)</div>
|
||||
</div>
|
||||
|
||||
<div class="modal" id="modal" role="dialog" aria-modal="true" aria-hidden="true">
|
||||
<div class="modal-inner">
|
||||
<div class="modal-hd">
|
||||
<div class="title" id="modalTitle">预览</div>
|
||||
<button class="close" type="button" id="modalClose">关闭(Esc)</button>
|
||||
</div>
|
||||
<div class="modal-bd">
|
||||
<img id="modalImg" src="" alt="预览图片" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
const modal = document.getElementById("modal");
|
||||
const modalImg = document.getElementById("modalImg");
|
||||
const modalTitle = document.getElementById("modalTitle");
|
||||
const closeBtn = document.getElementById("modalClose");
|
||||
|
||||
function openModal(src, title) {
|
||||
modalImg.src = src;
|
||||
modalTitle.textContent = title || "预览";
|
||||
modal.classList.add("open");
|
||||
modal.setAttribute("aria-hidden", "false");
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
modal.classList.remove("open");
|
||||
modal.setAttribute("aria-hidden", "true");
|
||||
modalImg.src = "";
|
||||
}
|
||||
|
||||
document.addEventListener("click", (e) => {
|
||||
const btn = e.target.closest("[data-preview]");
|
||||
if (btn) {
|
||||
const src = btn.getAttribute("data-preview");
|
||||
const img = document.querySelector(`img[src="${src}"]`);
|
||||
const title = (img && img.getAttribute("data-preview-title")) || "预览";
|
||||
openModal(src, title);
|
||||
return;
|
||||
}
|
||||
|
||||
const img = e.target.closest("img[data-preview-title]");
|
||||
if (img && img.src) {
|
||||
openModal(img.getAttribute("src"), img.getAttribute("data-preview-title"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.target === modal) closeModal();
|
||||
});
|
||||
|
||||
closeBtn.addEventListener("click", closeModal);
|
||||
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape") closeModal();
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
BIN
开发文档/第四部分上.png
Normal file
|
After Width: | Height: | Size: 4.3 MiB |
BIN
开发文档/第四部分下.png
Normal file
|
After Width: | Height: | Size: 3.3 MiB |