diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f650315 --- /dev/null +++ b/.gitignore @@ -0,0 +1,27 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules + +# next.js +/.next/ +/out/ + +# production +/build + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts \ No newline at end of file diff --git a/app/api/database-structure/route.ts b/app/api/database-structure/route.ts index 84ee395..c90e98f 100644 --- a/app/api/database-structure/route.ts +++ b/app/api/database-structure/route.ts @@ -1,3 +1,5 @@ +export const dynamic = "force-dynamic" + import { NextResponse } from "next/server" import { getDatabases, getDatabaseStructure } from "@/lib/mongodb-mock-connector" // 更新导入路径 diff --git a/app/api/users/route.ts b/app/api/users/route.ts index fe85bff..3156936 100644 --- a/app/api/users/route.ts +++ b/app/api/users/route.ts @@ -1,6 +1,6 @@ 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 { addUser, filterUsers, getDistinctTags, getUserById, queryUsers, type UserStatus, MOCK_USERS } from "@/lib/mock-users" // 中文名字生成器数据 const familyNames = [ @@ -197,30 +197,26 @@ function parseArrayParam(v: string | null) { return v.split(",").map((s) => s.trim()).filter(Boolean) } -export async function GET(req: NextRequest) { - const { searchParams } = new URL(req.url) +export const dynamic = "force-dynamic" - // 详情优先 - const id = searchParams.get('id') - if (id) { - const detail = getUserById(id) - return NextResponse.json({ data: detail }, { headers: { 'Cache-Control': 'no-store' } }) - } +export async function GET(request: Request) { + const { searchParams } = new URL(request.url) + const keyword = (searchParams.get("q") || "").trim() - // 列表 - const q = searchParams.get('q') ?? undefined - const tagsStr = searchParams.get('tags') ?? '' - const statusStr = searchParams.get('status') ?? '' - const rfmMin = Number(searchParams.get('rfmMin') ?? 0) - const rfmMax = Number(searchParams.get('rfmMax') ?? 100) - const page = Number(searchParams.get('page') ?? 1) - const pageSize = Number(searchParams.get('pageSize') ?? 20) + const filtered = keyword + ? MOCK_USERS.filter( + (u) => + u.name.includes(keyword) || + (u.nickname && u.nickname.includes(keyword)) || + u.tags.some((t) => t.includes(keyword)), + ) + : MOCK_USERS - 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' } }) + return NextResponse.json({ + success: true, + total: filtered.length, + items: filtered, + }) } export async function POST(req: NextRequest) { diff --git a/app/page.tsx b/app/page.tsx index abc0cdf..1f5b9eb 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,7 +1,7 @@ "use client" import { useState, useEffect } from "react" -import { Search, Users, TrendingUp, Database, RefreshCw, BarChart3, Activity, Globe } from 'lucide-react' +import { Search, Users, TrendingUp, Database, RefreshCw, BarChart3, Activity, Globe, Smartphone, Brain } from 'lucide-react' import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" @@ -10,6 +10,7 @@ import { useRouter } from "next/navigation" import { Toaster } from "@/components/ui/toaster" import UserSearch from '@/components/home/user-search' import UserList from '@/components/home/user-list' +import Link from "next/link" interface SystemStats { userCount: number @@ -36,6 +37,8 @@ function StatCard({ icon, label, value }: { icon: React.ReactNode; label: string ) } +export const dynamic = "force-static" + export default function OverviewPage() { const router = useRouter() const [searchQuery, setSearchQuery] = useState("") @@ -113,6 +116,33 @@ export default function OverviewPage() { return num.toString() } + const tiles = [ + { + href: "/user-portrait", + title: "用户画像", + desc: "管理与分群、标签与画像洞察", + icon: , + }, + { + href: "/devices", + title: "设备管理", + desc: "查看设备状态与执行任务", + icon: , + }, + { + href: "/data-platform", + title: "数据中台", + desc: "数据接入、质量监控与查询", + icon: , + }, + { + href: "/ai-assistant", + title: "AI 助手", + desc: "智能问答与自动化报表", + icon: , + }, + ] + return (
@@ -302,58 +332,21 @@ export default function OverviewPage() {
{/* 快速访问入口 */} -
- router.push("/data-platform")} - > - -
- -
-

数据中台

-

数据源管理与AI模型

-
-
- - router.push("/user-portrait")} - > - -
- -
-

用户画像

-

用户管理与标签体系

-
-
- - router.push("/ai-assistant")} - > - -
- -
-

AI智能助手

-

数据分析与报告生成

-
-
- - router.push("/intelligent-search")} - > - -
- -
-

智能搜索

-

全局搜索与AI分析

-
-
+
+ {tiles.map((t) => ( + + + {t.title} + {t.icon} + + +

{t.desc}

+ +
+
+ ))}
{/* 快速指标示例(可后续接入真实数据) */} diff --git a/app/user-portrait/page.tsx b/app/user-portrait/page.tsx index 9142f78..08ea357 100644 --- a/app/user-portrait/page.tsx +++ b/app/user-portrait/page.tsx @@ -1,239 +1,101 @@ -"use client" - -import { useEffect, useMemo, useState } from "react" -import Link from "next/link" -import MobileHeader from "@/app/components/MobileHeader" -import BottomNav from "@/app/components/BottomNav" -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" -import { Card, CardContent } from "@/components/ui/card" -import { Button } from "@/components/ui/button" +import { Suspense } from "react" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Input } from "@/components/ui/input" -import { Badge } from "@/components/ui/badge" -import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog" -import { Label } from "@/components/ui/label" -import { Checkbox } from "@/components/ui/checkbox" -import { Search, Filter, Plus } from 'lucide-react' -import FilterDrawer, { type FilterValues } from "@/components/user-portrait/filter-drawer" +import { Button } from "@/components/ui/button" +import Link from "next/link" -type User = { - id: string - name: string - phone: string - email: string - tags: string[] - rfmScore: number - lastActivity: string - status: "活跃" | "沉睡" | "已封禁" +export const dynamic = "force-dynamic" + +async function fetchUsers(q: string) { + const url = q ? `/api/users?q=${encodeURIComponent(q)}` : "/api/users" + const res = await fetch(url, { cache: "no-store" }) + if (!res.ok) return { items: [], total: 0 } + return res.json() } -type UsersResponse = { success: true; data: { items: User[]; total: number; page: number; pageSize: number } } - -export default function UserPortraitPage() { - const [users, setUsers] = useState([]) - const [total, setTotal] = useState(0) - - const [searchQuery, setSearchQuery] = useState("") - const [isAddingUser, setIsAddingUser] = useState(false) - const [newUser, setNewUser] = useState({ name: "", phone: "", email: "", tags: [] as string[] }) - - const [filterOpen, setFilterOpen] = useState(false) - const [allTags, setAllTags] = useState([]) - const [filters, setFilters] = useState({ tags: [], status: [], rfm: [0, 100] }) - - const queryString = useMemo(() => { - const p = new URLSearchParams() - if (searchQuery) p.set("q", searchQuery) - if (filters.tags.length) p.set("tags", filters.tags.join(",")) - if (filters.status.length) p.set("status", filters.status.join(",")) - p.set("rfmMin", String(filters.rfm[0])) - p.set("rfmMax", String(filters.rfm[1])) - p.set("page", "1") - p.set("pageSize", "50") - return p.toString() - }, [searchQuery, filters]) - - useEffect(() => { - fetch(`/api/users?${queryString}`) - .then((r) => r.json()) - .then((res: UsersResponse) => { - if (res?.success) { - setUsers(res.data.items) - setTotal(res.data.total) - } - }) - .catch(() => {}) - }, [queryString]) - - useEffect(() => { - fetch("/api/users?meta=tags") - .then((r) => r.json()) - .then((res: any) => setAllTags(res?.data?.tags ?? [])) - .catch(() => {}) - }, []) - - const handleAddUser = async () => { - const resp = await fetch("/api/users", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(newUser), - }) - const data = await resp.json() - if (data?.success) { - setIsAddingUser(false) - setNewUser({ name: "", phone: "", email: "", tags: [] }) - // 触发刷新 - fetch(`/api/users?${queryString}`) - .then((r) => r.json()) - .then((res: UsersResponse) => { - if (res?.success) { - setUsers(res.data.items) - setTotal(res.data.total) - } - }) - } - } - +function Tabs({ active = "users" }: { active?: "users" | "tags" }) { return ( -
- {}} title="用户画像" /> - -
-
-
-
-

用户画像

-

管理与分群

-
-
共 {total} 人
-
- -
- - - 用户管理 - 标签管理 - - - -
-
- - setSearchQuery(e.target.value)} /> -
- - -
- -
- {users.map((u) => ( - - -
-
- - {u.name} - -

- 最后活跃 {new Date(u.lastActivity).toLocaleDateString("zh-CN")} -

-
-
-
{u.phone}
-
{u.email}
-
-
-
- {u.tags.slice(0, 2).map((t) => ( - {t} - ))} - {u.tags.length > 2 && ( - +{u.tags.length - 2} - )} -
-
-
{u.rfmScore}
-
- - {u.status} - -
-
-
-
- ))} -
-
- - -
标签管理将在接入数据字典后提供配置与统计
-
-
-
-
-
- - - - {/* 筛选抽屉 */} - setFilters(v)} - /> - - {/* 添加用户 */} - - - 添加新用户 -
-
- - setNewUser((p) => ({ ...p, name: e.target.value }))} /> -
-
- - setNewUser((p) => ({ ...p, phone: e.target.value }))} /> -
-
- - setNewUser((p) => ({ ...p, email: e.target.value }))} /> -
- {!!allTags.length && ( -
- -
- {allTags.map((t) => ( - - ))} -
-
- )} -
-
- - -
-
-
+
+
用户管理
+ 标签管理
) } + +async function UsersList({ q }: { q: string }) { + const { items } = await fetchUsers(q) + return ( +
+ {items.map((u: any) => ( + + + {u.name}{u.nickname ? ` · ${u.nickname}` : ""} +

最后活跃:{new Date(u.lastActive).toLocaleString()}

+
+ +
+ avatar +
+ {u.tags?.slice(0, 3).map((t: string) => ( + {t} + ))} +
+
+ +
+
+ ))} + {items.length === 0 && ( +

暂无用户数据

+ )} +
+ ) +} + +export default async function UserPortraitPage({ + searchParams, +}: { + searchParams: { q?: string } +}) { + const q = (searchParams?.q || "").trim() + + return ( +
+ + + 用户画像 +

管理与分群

+
+ +
+ +
+
+ + +
+ +
+
+ + 加载中...

}> + {/* @ts-expect-error Async Server Component */} + +
+
+
+
+ ) +} diff --git a/components/user-portrait/filter-drawer.tsx b/components/user-portrait/filter-drawer.tsx index 39c313a..3230354 100644 --- a/components/user-portrait/filter-drawer.tsx +++ b/components/user-portrait/filter-drawer.tsx @@ -1,19 +1,19 @@ "use client" -import { useEffect, useState } from "react" -import { X } from 'lucide-react' +import { useMemo, useState } from "react" +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog" import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" import { Checkbox } from "@/components/ui/checkbox" import { Label } from "@/components/ui/label" -import { Input } from "@/components/ui/input" export type FilterValues = { tags: string[] - status: Array<"活跃" | "沉睡" | "已封禁"> + status: string[] rfm: [number, number] } -interface Props { +type Props = { open: boolean onOpenChange: (v: boolean) => void allTags: string[] @@ -21,131 +21,122 @@ interface Props { onApply: (v: FilterValues) => void } -export default function FilterDrawer({ open, onOpenChange, allTags, value, onApply }: Props) { +const STATUS_OPTIONS = ["活跃", "沉睡", "已封禁"] as const + +export default function FilterDrawer({ + open, + onOpenChange, + allTags, + value, + onApply, +}: Props) { const [local, setLocal] = useState(value) - useEffect(() => setLocal(value), [value, open]) + // 同步外部变更 + useMemo(() => setLocal(value), [value]) - const toggleTag = (t: string, checked: boolean) => { - setLocal((prev) => ({ - ...prev, - tags: checked ? Array.from(new Set([...prev.tags, t])) : prev.tags.filter((x) => x !== t), - })) - } - - const toggleStatus = (s: "活跃" | "沉睡" | "已封禁", checked: boolean) => { - setLocal((prev) => ({ - ...prev, - status: checked ? Array.from(new Set([...prev.status, s])) : prev.status.filter((x) => x !== s), - })) - } - - const apply = () => { - onApply(local) - onOpenChange(false) - } - - const reset = () => { - const init: FilterValues = { tags: [], status: [], rfm: [0, 100] } - setLocal(init) - onApply(init) - } + const toggleArrayVal = (arr: string[], val: string, checked: boolean) => + checked ? Array.from(new Set([...arr, val])) : arr.filter((x) => x !== val) return ( -
-
onOpenChange(false)} - /> - -
+ + ) } diff --git a/lib/mock-users.ts b/lib/mock-users.ts index 7b36f84..b71ccaf 100644 --- a/lib/mock-users.ts +++ b/lib/mock-users.ts @@ -15,6 +15,49 @@ export interface User { lastActiveAt: string } +export interface MockUser { + id: string + name: string + nickname?: string + avatar?: string + tags: string[] + lastActive: string + city?: string + valueScore?: number +} + +export const MOCK_USERS: MockUser[] = [ + { + id: "u_1001", + name: "张三", + nickname: "技术控", + avatar: "/user-avatar-zhangsan.png", + tags: ["技术爱好者", "夜猫子"], + lastActive: "2025-08-01T10:20:00Z", + city: "深圳", + valueScore: 86, + }, + { + id: "u_1002", + name: "李四", + nickname: "内容创作者", + avatar: "/user-avatar-lisi.png", + tags: ["短视频", "自动化工具"], + lastActive: "2025-08-06T14:30:00Z", + city: "广州", + valueScore: 72, + }, + { + id: "u_1003", + name: "王雷", + avatar: "/avatar-wanglei.png", + tags: ["效率提升"], + lastActive: "2025-08-07T08:05:00Z", + city: "成都", + valueScore: 64, + }, +] + const familyNames = ['张','李','王','赵','刘','陈','杨','黄','周','吴','徐','孙','胡','朱','高','林','何','郭','马','罗'] const givenNames = ['伟','芳','娜','敏','静','秀英','丽','强','磊','军','洋','艳','勇','杰','娟','涛','明','超','霞','平','俊','凯','佳','鑫','鹏','晨','倩','颖','梅','慧','雪','宇','涵','宁','璐','龙','震','航','璟','钰'] const tagPool = ['高价值','近7日活跃','新客','回流','社群达人','潜在复购','高互动','低客单','私域粉','公众号粉'] diff --git a/types/device.ts b/types/device.ts new file mode 100644 index 0000000..82655f7 --- /dev/null +++ b/types/device.ts @@ -0,0 +1,58 @@ +export type DeviceStatus = "online" | "offline" | "unknown" + +export interface Device { + id: string + name: string + wechatId?: string + group?: string + tags?: string[] + status: DeviceStatus + lastSeen?: string +} + +export interface CreateDeviceParams { + name: string + wechatId?: string + group?: string + tags?: string[] +} + +export interface UpdateDeviceParams extends Partial { + id: string +} + +export interface QueryDeviceParams { + keyword?: string + tags?: string[] + dateRange?: { start: string; end: string } + page?: number + pageSize?: number +} + +export interface DeviceStats { + id: string + tasksToday: number + uptimePercent: number +} + +export interface DeviceTaskRecord { + id: string + deviceId: string + type: string + status: "success" | "failed" + time: string +} + +export interface PaginatedResponse { + items: T[] + total: number + page: number + pageSize: number + totalPages: number +} + +export interface ApiResponse { + code: number + message: string + data: T | null +} diff --git a/types/scenario.ts b/types/scenario.ts new file mode 100644 index 0000000..df19822 --- /dev/null +++ b/types/scenario.ts @@ -0,0 +1,60 @@ +export type ScenarioStatus = "draft" | "running" | "paused" | "completed" + +export interface ScenarioBase { + id: string + name: string + type: string + status: ScenarioStatus + creator: string + createdAt: string + updatedAt: string + description?: string +} + +export interface CreateScenarioParams { + name: string + type: string + description?: string +} + +export interface UpdateScenarioParams extends Partial { + id: string +} + +export interface QueryScenarioParams { + type?: string + status?: ScenarioStatus + keyword?: string + dateRange?: { start: string; end: string } + page?: number + pageSize?: number +} + +export interface ScenarioStats { + id: string + impressions: number + clicks: number + conversions: number +} + +export interface AcquisitionRecord { + id: string + scenarioId: string + userId: string + time: string + channel: string +} + +export interface PaginatedResponse { + items: T[] + total: number + page: number + pageSize: number + totalPages: number +} + +export interface ApiResponse { + code: number + message: string + data: T | null +}