feat: add leads page and refresh ui components
Overhaul all core UI components (button, input, select, textarea, badge, alert, card) to use consistent tailwind color classes, add smooth transitions and hover states for better interactivity. Add TypeScript types, updated API functions, and utility helpers for lead management with filtering and pagination support. Build a fully functional leads management page with status and source filters, lead data formatting (travel date, budget, headcount), loading states, empty states, and a polished responsive table layout. Add comprehensive admin sidebar, workspace, and leads table styling to the global CSS.
This commit is contained in:
21
src/api.ts
21
src/api.ts
@@ -53,18 +53,29 @@ export type ProductDetailSection = {
|
||||
blocks: ProductDetailBlock[];
|
||||
};
|
||||
|
||||
export type LeadStatus = "new" | "assigned" | "contacted" | "planning" | "won" | "invalid";
|
||||
|
||||
export type Lead = {
|
||||
id: string;
|
||||
destination?: string | null;
|
||||
phone: string;
|
||||
travelDate?: string | null;
|
||||
peopleCount?: number | null;
|
||||
budgetMin?: number | null;
|
||||
budgetMax?: number | null;
|
||||
note?: string | null;
|
||||
sourcePage?: string | null;
|
||||
status: "new" | "assigned" | "contacted" | "planning" | "won" | "invalid";
|
||||
status: LeadStatus;
|
||||
createdAt: string;
|
||||
sourceProduct?: { id: string; title: string } | null;
|
||||
assignedUser?: { id: string; name: string } | null;
|
||||
};
|
||||
|
||||
export type LeadListParams = {
|
||||
status?: LeadStatus;
|
||||
take?: number;
|
||||
};
|
||||
|
||||
export type MediaAsset = {
|
||||
id: string;
|
||||
url: string;
|
||||
@@ -314,8 +325,12 @@ export async function reorderSiteConfigItems(module: SiteModule, itemIds: string
|
||||
});
|
||||
}
|
||||
|
||||
export async function getLeads() {
|
||||
return request<{ items: Lead[] }>("/api/admin/leads");
|
||||
export async function getLeads(params: LeadListParams = {}) {
|
||||
const search = new URLSearchParams();
|
||||
if (params.status) search.set("status", params.status);
|
||||
if (params.take) search.set("take", String(params.take));
|
||||
const query = search.toString();
|
||||
return request<{ items: Lead[] }>(`/api/admin/leads${query ? `?${query}` : ""}`);
|
||||
}
|
||||
|
||||
export async function updateLeadStatus(id: string, status: Lead["status"]) {
|
||||
|
||||
@@ -3,13 +3,13 @@ import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const alertVariants = cva("relative w-full rounded-lg border p-4 text-sm", {
|
||||
const alertVariants = cva("relative w-full rounded-lg border px-4 py-3 text-sm leading-6 shadow-sm", {
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border-[var(--border)] bg-[var(--card)] text-[var(--card-foreground)]",
|
||||
warning: "border-[var(--warning-border)] bg-[var(--warning-muted)] text-[var(--warning)]",
|
||||
destructive: "border-[var(--destructive-border)] bg-[var(--destructive-muted)] text-[var(--destructive)]",
|
||||
success: "border-[var(--success-border)] bg-[var(--success-muted)] text-[var(--success)]",
|
||||
default: "border-slate-200 bg-white text-slate-700",
|
||||
warning: "border-amber-200 bg-amber-50 text-amber-800",
|
||||
destructive: "border-red-200 bg-red-50 text-red-700",
|
||||
success: "border-emerald-200 bg-emerald-50 text-emerald-700",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
||||
@@ -3,15 +3,15 @@ import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const badgeVariants = cva("inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors", {
|
||||
const badgeVariants = cva("inline-flex items-center rounded-full border px-2.5 py-1 text-xs font-bold leading-none transition-colors", {
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border-transparent bg-[var(--primary)] text-[var(--primary-foreground)]",
|
||||
secondary: "border-transparent bg-[var(--secondary)] text-[var(--secondary-foreground)]",
|
||||
outline: "border-[var(--border)] text-[var(--foreground)]",
|
||||
success: "border-transparent bg-[var(--success-muted)] text-[var(--success)]",
|
||||
warning: "border-transparent bg-[var(--warning-muted)] text-[var(--warning)]",
|
||||
muted: "border-transparent bg-[var(--muted)] text-[var(--muted-foreground)]",
|
||||
default: "border-teal-200 bg-teal-50 text-teal-700",
|
||||
secondary: "border-slate-200 bg-slate-100 text-slate-700",
|
||||
outline: "border-slate-300 bg-white text-slate-700",
|
||||
success: "border-emerald-200 bg-emerald-50 text-emerald-700",
|
||||
warning: "border-amber-200 bg-amber-50 text-amber-800",
|
||||
muted: "border-slate-200 bg-slate-100 text-slate-500",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
||||
@@ -5,22 +5,25 @@ import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--background)] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
"inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-semibold transition-[background-color,border-color,color,box-shadow,transform] duration-150 ease-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--background)] active:scale-[0.98] disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-45 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-[var(--primary)] text-[var(--primary-foreground)] hover:bg-[var(--primary-hover)]",
|
||||
destructive: "bg-[var(--destructive)] text-[var(--destructive-foreground)] hover:bg-[var(--destructive-hover)]",
|
||||
outline: "border border-[var(--input)] bg-[var(--background)] hover:bg-[var(--accent)] hover:text-[var(--accent-foreground)]",
|
||||
secondary: "bg-[var(--secondary)] text-[var(--secondary-foreground)] hover:bg-[var(--secondary-hover)]",
|
||||
ghost: "hover:bg-[var(--accent)] hover:text-[var(--accent-foreground)]",
|
||||
default: "border border-[var(--primary)] bg-[var(--primary)] text-[var(--primary-foreground)] shadow-sm hover:bg-[var(--primary-hover)] hover:shadow-md",
|
||||
destructive:
|
||||
"border border-[var(--destructive)] bg-[var(--destructive)] text-[var(--destructive-foreground)] shadow-sm hover:bg-[var(--destructive-hover)] hover:shadow-md",
|
||||
outline:
|
||||
"border border-slate-300 bg-white text-slate-800 shadow-sm hover:border-slate-400 hover:bg-slate-50 hover:text-slate-950 hover:shadow-md",
|
||||
secondary:
|
||||
"border border-slate-200 bg-slate-100 text-slate-900 shadow-sm hover:bg-slate-200",
|
||||
ghost: "text-slate-700 hover:bg-slate-100 hover:text-slate-950",
|
||||
link: "text-[var(--primary)] underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-8 rounded-md px-3 text-xs",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
icon: "h-9 w-9",
|
||||
default: "h-11 px-4 py-2",
|
||||
sm: "h-9 rounded-lg px-3 text-xs",
|
||||
lg: "h-12 rounded-xl px-8",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
||||
@@ -3,22 +3,22 @@ import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("rounded-lg border border-[var(--border)] bg-[var(--card)] text-[var(--card-foreground)] shadow-sm", className)} {...props} />
|
||||
<div ref={ref} className={cn("rounded-xl border border-slate-200 bg-white text-slate-950 shadow-sm", className)} {...props} />
|
||||
));
|
||||
Card.displayName = "Card";
|
||||
|
||||
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
|
||||
<div ref={ref} className={cn("flex flex-col gap-1.5 p-6", className)} {...props} />
|
||||
));
|
||||
CardHeader.displayName = "CardHeader";
|
||||
|
||||
const CardTitle = React.forwardRef<HTMLHeadingElement, React.HTMLAttributes<HTMLHeadingElement>>(({ className, ...props }, ref) => (
|
||||
<h3 ref={ref} className={cn("text-2xl font-semibold leading-none tracking-normal", className)} {...props} />
|
||||
<h3 ref={ref} className={cn("text-2xl font-bold leading-tight tracking-normal text-slate-950", className)} {...props} />
|
||||
));
|
||||
CardTitle.displayName = "CardTitle";
|
||||
|
||||
const CardDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(({ className, ...props }, ref) => (
|
||||
<p ref={ref} className={cn("text-sm text-[var(--muted-foreground)]", className)} {...props} />
|
||||
<p ref={ref} className={cn("text-sm leading-6 text-slate-500", className)} {...props} />
|
||||
));
|
||||
CardDescription.displayName = "CardDescription";
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLI
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border border-[var(--input)] bg-[var(--background)] px-3 py-2 text-sm text-[var(--foreground)] shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-[var(--muted-foreground)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"flex h-11 w-full rounded-lg border border-slate-300 bg-white px-3.5 py-2 text-sm text-slate-950 shadow-sm transition-[background-color,border-color,box-shadow] duration-150 file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-slate-400 hover:border-slate-400 focus-visible:border-teal-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-teal-600/20 disabled:cursor-not-allowed disabled:bg-slate-100 disabled:text-slate-500",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
|
||||
@@ -6,7 +6,7 @@ const NativeSelect = React.forwardRef<HTMLSelectElement, React.SelectHTMLAttribu
|
||||
<select
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border border-[var(--input)] bg-[var(--background)] px-3 py-2 text-sm text-[var(--foreground)] shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"flex h-11 w-full rounded-lg border border-slate-300 bg-white px-3.5 py-2 text-sm font-medium text-slate-950 shadow-sm transition-[background-color,border-color,box-shadow] duration-150 hover:border-slate-400 focus-visible:border-teal-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-teal-600/20 disabled:cursor-not-allowed disabled:bg-slate-100 disabled:text-slate-500",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { cn } from "@/lib/utils";
|
||||
const Textarea = React.forwardRef<HTMLTextAreaElement, React.TextareaHTMLAttributes<HTMLTextAreaElement>>(({ className, ...props }, ref) => (
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex min-h-24 w-full rounded-md border border-[var(--input)] bg-[var(--background)] px-3 py-2 text-sm text-[var(--foreground)] shadow-sm placeholder:text-[var(--muted-foreground)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"flex min-h-28 w-full rounded-lg border border-slate-300 bg-white px-3.5 py-2.5 text-sm text-slate-950 shadow-sm transition-[background-color,border-color,box-shadow] duration-150 placeholder:text-slate-400 hover:border-slate-400 focus-visible:border-teal-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-teal-600/20 disabled:cursor-not-allowed disabled:bg-slate-100 disabled:text-slate-500",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
|
||||
@@ -1,71 +1,159 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { RefreshCcw } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { RefreshCcw, RotateCcw } from "lucide-react";
|
||||
|
||||
import { getLeads, updateLeadStatus } from "@/api";
|
||||
import { getLeads } from "@/api";
|
||||
import type { Lead } from "@/api";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { NativeSelect } from "@/components/ui/select";
|
||||
import { formatDate, leadStatusLabels } from "@/lib/admin-utils";
|
||||
import type { Notify } from "@/types/admin";
|
||||
import {
|
||||
formatBudget,
|
||||
formatPeopleCount,
|
||||
formatTravelDate,
|
||||
LEAD_TAKE_LIMIT,
|
||||
matchesSourceFilter,
|
||||
sourceFilters,
|
||||
sourceSubtitle,
|
||||
sourceTitle,
|
||||
statusFilters,
|
||||
statusVariant,
|
||||
type LeadSourceFilter,
|
||||
type LeadStatusFilter,
|
||||
} from "./lead-helpers";
|
||||
|
||||
export function LeadsPage({ notify }: { notify: Notify }) {
|
||||
export function LeadsPage(_props: { notify: Notify }) {
|
||||
const [leads, setLeads] = useState<Lead[]>([]);
|
||||
const [statusFilter, setStatusFilter] = useState<LeadStatusFilter>("all");
|
||||
const [sourceFilter, setSourceFilter] = useState<LeadSourceFilter>("all");
|
||||
const [message, setMessage] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const load = async () => {
|
||||
const result = await getLeads();
|
||||
const load = useCallback(
|
||||
async (options: { preserveMessage?: boolean } = {}) => {
|
||||
setLoading(true);
|
||||
if (!options.preserveMessage) setMessage("");
|
||||
try {
|
||||
const result = await getLeads({ status: statusFilter === "all" ? undefined : statusFilter, take: LEAD_TAKE_LIMIT });
|
||||
setLeads(result.items);
|
||||
};
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : "线索加载失败";
|
||||
setMessage(errorMessage);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[statusFilter],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
load().catch((err) => setMessage(err.message));
|
||||
}, []);
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const changeStatus = async (lead: Lead, status: Lead["status"]) => {
|
||||
await updateLeadStatus(lead.id, status);
|
||||
setMessage("线索状态已更新");
|
||||
notify({ tone: "success", title: "线索已更新", message: `${lead.phone} 已标记为${leadStatusLabels[status]}。` });
|
||||
await load();
|
||||
const visibleLeads = useMemo(() => leads.filter((lead) => matchesSourceFilter(lead, sourceFilter)), [leads, sourceFilter]);
|
||||
|
||||
const resetFilters = () => {
|
||||
setStatusFilter("all");
|
||||
setSourceFilter("all");
|
||||
setMessage("");
|
||||
if (statusFilter === "all") void load();
|
||||
};
|
||||
|
||||
const emptyMessage = loading
|
||||
? "正在加载客户需求..."
|
||||
: visibleLeads.length
|
||||
? ""
|
||||
: leads.length
|
||||
? "当前来源筛选下暂无客户需求。"
|
||||
: "暂无客户需求。";
|
||||
|
||||
return (
|
||||
<section className="work-area">
|
||||
<header className="section-head">
|
||||
<div>
|
||||
<h2>需求线索跟进</h2>
|
||||
<p>对应前台「提交出行需求」和详情页「立即咨询」。</p>
|
||||
<p>对应前台「首页快速定制」「提交出行需求」和详情页「立即咨询」。</p>
|
||||
</div>
|
||||
<Button variant="outline" onClick={load}>
|
||||
<RefreshCcw size={17} />
|
||||
刷新
|
||||
</Button>
|
||||
</header>
|
||||
{message ? <p className="notice">{message}</p> : null}
|
||||
<div className="lead-table">
|
||||
<div className="lead-table-head">
|
||||
<span>客户</span>
|
||||
<span>需求</span>
|
||||
<span>来源</span>
|
||||
<span>状态</span>
|
||||
</div>
|
||||
{leads.map((lead) => (
|
||||
<article className="lead-row" key={lead.id}>
|
||||
<span>
|
||||
<b>{lead.phone}</b>
|
||||
<small>{formatDate(lead.createdAt)}</small>
|
||||
</span>
|
||||
<span>{lead.destination || lead.note || "未填写"}</span>
|
||||
<span>{lead.sourceProduct?.title || lead.sourcePage || "H5 表单"}</span>
|
||||
<NativeSelect value={lead.status} onChange={(event) => changeStatus(lead, event.target.value as Lead["status"])}>
|
||||
{Object.entries(leadStatusLabels).map(([value, label]) => (
|
||||
<option value={value} key={value}>
|
||||
{label}
|
||||
|
||||
<form
|
||||
className="lead-toolbar"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void load();
|
||||
}}
|
||||
>
|
||||
<label className="lead-filter-field">
|
||||
状态
|
||||
<NativeSelect value={statusFilter} onChange={(event) => setStatusFilter(event.target.value as LeadStatusFilter)} disabled={loading}>
|
||||
{statusFilters.map((item) => (
|
||||
<option value={item.value} key={item.value}>
|
||||
{item.label}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</label>
|
||||
<label className="lead-filter-field">
|
||||
来源
|
||||
<NativeSelect value={sourceFilter} onChange={(event) => setSourceFilter(event.target.value as LeadSourceFilter)} disabled={loading}>
|
||||
{sourceFilters.map((item) => (
|
||||
<option value={item.value} key={item.value}>
|
||||
{item.label}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</label>
|
||||
<Button variant="outline" disabled={loading}>
|
||||
<RefreshCcw size={16} />
|
||||
{loading ? "查询中" : "查询"}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" onClick={resetFilters} disabled={loading}>
|
||||
<RotateCcw size={16} />
|
||||
重置
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{message ? <p className="notice">{message}</p> : null}
|
||||
|
||||
<div className="lead-table" aria-busy={loading}>
|
||||
<div className="lead-table-head">
|
||||
<span>客户</span>
|
||||
<span>提交时间</span>
|
||||
<span>需求内容</span>
|
||||
<span>出行信息</span>
|
||||
<span>来源</span>
|
||||
<span>状态</span>
|
||||
</div>
|
||||
{visibleLeads.map((lead) => (
|
||||
<article className="lead-row" key={lead.id}>
|
||||
<span className="lead-customer-cell">
|
||||
<b>{lead.phone}</b>
|
||||
</span>
|
||||
<span className="lead-created-cell">
|
||||
<small>{formatDate(lead.createdAt)}</small>
|
||||
</span>
|
||||
<span className="lead-demand-cell">
|
||||
<b>{lead.destination || "未填写目的地"}</b>
|
||||
<small>{lead.note || "暂无补充说明"}</small>
|
||||
</span>
|
||||
<span className="lead-trip-cell">
|
||||
<small>日期:{formatTravelDate(lead.travelDate)}</small>
|
||||
<small>人数:{formatPeopleCount(lead.peopleCount)}</small>
|
||||
<small>预算:{formatBudget(lead)}</small>
|
||||
</span>
|
||||
<span className="lead-source-cell">
|
||||
<b>{sourceTitle(lead)}</b>
|
||||
<small>{sourceSubtitle(lead)}</small>
|
||||
</span>
|
||||
<span className="lead-status-cell">
|
||||
<Badge className="lead-status-chip" variant={statusVariant(lead.status)}>
|
||||
{leadStatusLabels[lead.status]}
|
||||
</Badge>
|
||||
</span>
|
||||
</article>
|
||||
))}
|
||||
{!leads.length ? <p className="muted empty-row">暂无客户需求。</p> : null}
|
||||
{emptyMessage ? <p className="muted empty-row">{emptyMessage}</p> : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
91
src/pages/leads/lead-helpers.ts
Normal file
91
src/pages/leads/lead-helpers.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import type { Lead, LeadStatus } from "@/api";
|
||||
import { leadStatusLabels } from "@/lib/admin-utils";
|
||||
|
||||
export type LeadStatusFilter = "all" | LeadStatus;
|
||||
export type KnownLeadSource = "home_inline" | "search_inline" | "demand_page" | "product_consult";
|
||||
export type LeadSourceFilter = "all" | KnownLeadSource | "other";
|
||||
export type LeadStatusVariant = "default" | "secondary" | "success" | "warning" | "muted";
|
||||
|
||||
export const LEAD_TAKE_LIMIT = 200;
|
||||
|
||||
export const statusFilters: Array<{ value: LeadStatusFilter; label: string }> = [
|
||||
{ value: "all", label: "全部状态" },
|
||||
{ value: "new", label: leadStatusLabels.new },
|
||||
{ value: "assigned", label: leadStatusLabels.assigned },
|
||||
{ value: "contacted", label: leadStatusLabels.contacted },
|
||||
{ value: "planning", label: leadStatusLabels.planning },
|
||||
{ value: "won", label: leadStatusLabels.won },
|
||||
{ value: "invalid", label: leadStatusLabels.invalid },
|
||||
];
|
||||
|
||||
export const sourcePageLabels: Record<KnownLeadSource, string> = {
|
||||
home_inline: "首页快速定制",
|
||||
search_inline: "搜索页快速定制",
|
||||
demand_page: "提交需求页",
|
||||
product_consult: "产品预订咨询",
|
||||
};
|
||||
|
||||
export const sourceFilters: Array<{ value: LeadSourceFilter; label: string }> = [
|
||||
{ value: "all", label: "全部来源" },
|
||||
{ value: "home_inline", label: sourcePageLabels.home_inline },
|
||||
{ value: "search_inline", label: sourcePageLabels.search_inline },
|
||||
{ value: "demand_page", label: sourcePageLabels.demand_page },
|
||||
{ value: "product_consult", label: sourcePageLabels.product_consult },
|
||||
{ value: "other", label: "其他来源" },
|
||||
];
|
||||
|
||||
export function matchesSourceFilter(lead: Lead, sourceFilter: LeadSourceFilter) {
|
||||
if (sourceFilter === "all") return true;
|
||||
if (sourceFilter === "other") return !isKnownSource(lead.sourcePage);
|
||||
return lead.sourcePage === sourceFilter;
|
||||
}
|
||||
|
||||
export function sourceTitle(lead: Lead) {
|
||||
if (lead.sourceProduct?.title) return lead.sourceProduct.title;
|
||||
if (isKnownSource(lead.sourcePage)) return sourcePageLabels[lead.sourcePage];
|
||||
return lead.sourcePage || "H5 表单";
|
||||
}
|
||||
|
||||
export function sourceSubtitle(lead: Lead) {
|
||||
if (lead.sourceProduct?.title && isKnownSource(lead.sourcePage)) return sourcePageLabels[lead.sourcePage];
|
||||
if (lead.assignedUser?.name) return `管家:${lead.assignedUser.name}`;
|
||||
return "未分配管家";
|
||||
}
|
||||
|
||||
export function formatTravelDate(value?: string | null) {
|
||||
if (!value) return "未填写";
|
||||
const datePart = value.slice(0, 10);
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(datePart)) return datePart.replace(/-/g, "/");
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
return new Intl.DateTimeFormat("zh-CN", { year: "numeric", month: "2-digit", day: "2-digit" }).format(date);
|
||||
}
|
||||
|
||||
export function formatPeopleCount(value?: number | null) {
|
||||
return typeof value === "number" && Number.isFinite(value) ? `${value}人` : "未填写";
|
||||
}
|
||||
|
||||
export function formatBudget(lead: Lead) {
|
||||
const min = typeof lead.budgetMin === "number" && Number.isFinite(lead.budgetMin) ? lead.budgetMin : null;
|
||||
const max = typeof lead.budgetMax === "number" && Number.isFinite(lead.budgetMax) ? lead.budgetMax : null;
|
||||
if (min !== null && max !== null) return min === max ? formatMoney(min) : `${formatMoney(min)} - ${formatMoney(max)}`;
|
||||
if (min !== null) return `${formatMoney(min)}起`;
|
||||
if (max !== null) return `${formatMoney(max)}内`;
|
||||
return "未填写";
|
||||
}
|
||||
|
||||
export function statusVariant(status: LeadStatus): LeadStatusVariant {
|
||||
if (status === "won") return "success";
|
||||
if (status === "new" || status === "planning") return "warning";
|
||||
if (status === "invalid") return "muted";
|
||||
if (status === "assigned") return "secondary";
|
||||
return "default";
|
||||
}
|
||||
|
||||
function isKnownSource(value?: string | null): value is KnownLeadSource {
|
||||
return Boolean(value && value in sourcePageLabels);
|
||||
}
|
||||
|
||||
function formatMoney(value: number) {
|
||||
return `¥${new Intl.NumberFormat("zh-CN").format(value)}`;
|
||||
}
|
||||
1196
src/styles.css
1196
src/styles.css
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user