首页添加二级路由
This commit is contained in:
@@ -1,5 +1,305 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useMemo, useState } from "react"
|
||||
import { Controller, useForm } from "react-hook-form"
|
||||
import { Link } from "react-router-dom"
|
||||
import { toast } from "sonner"
|
||||
import { z } from "zod"
|
||||
import DataTable from "@/components/data-table"
|
||||
import Page from "@/components/page"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Field, FieldError, FieldLabel } from "@/components/ui/field"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import type { ChannelRecord } from "@/lib/models/channel"
|
||||
|
||||
const filterSchema = z.object({
|
||||
expired_at_end: z.string().optional(),
|
||||
product: z.string().optional(),
|
||||
plan: z.string().optional(),
|
||||
account: z.string().optional(),
|
||||
status: z.string().optional(),
|
||||
})
|
||||
|
||||
type FormValues = z.infer<typeof filterSchema>
|
||||
|
||||
const initialData: ChannelRecord[] = [
|
||||
{
|
||||
id: "1",
|
||||
startAt: "2025.04.09",
|
||||
product: "极光IP",
|
||||
plan: "测试卡(带宽)",
|
||||
account: "ft8765",
|
||||
password: "765修改",
|
||||
ip: "甘肃-兰州125.75.111.127",
|
||||
server: "p3.125.74.129.38.sdip.cc",
|
||||
connections: 1,
|
||||
expiredAt: "2025.04.09",
|
||||
remaining: "已过期",
|
||||
online: "查看",
|
||||
download: "下载",
|
||||
status: "",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
startAt: "2025.04.09",
|
||||
product: "极光IP",
|
||||
plan: "测试卡(带宽)",
|
||||
account: "ft8765",
|
||||
password: "765修改",
|
||||
ip: "辽宁-辽阳123.190.161.192",
|
||||
server: "p16.cqdwx4.sdip.cc",
|
||||
connections: 1,
|
||||
expiredAt: "2025.04.09",
|
||||
remaining: "0天00时37分",
|
||||
online: "查看",
|
||||
download: "下载",
|
||||
status: "",
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
startAt: "2025.04.09",
|
||||
product: "极光IP",
|
||||
plan: "静态-项目-20M-月卡",
|
||||
account: "ft8765",
|
||||
password: "765修改",
|
||||
ip: "黑龙江-鸡西100.100.4.249",
|
||||
server: "p37.sdlw2.sdip.cc",
|
||||
connections: 1,
|
||||
expiredAt: "2025.04.09",
|
||||
remaining: "0天00时37分",
|
||||
online: "查看",
|
||||
download: "下载",
|
||||
status: "",
|
||||
},
|
||||
]
|
||||
|
||||
export default function ChannelsPage() {
|
||||
return <Page>IP管理</Page>
|
||||
const [filterValues, setFilterValues] = useState<FormValues>({})
|
||||
const [status, setStatus] = useState<"load" | "done">("done")
|
||||
// 行选择状态
|
||||
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({})
|
||||
|
||||
const { control, handleSubmit, reset } = useForm<FormValues>({
|
||||
resolver: zodResolver(filterSchema),
|
||||
defaultValues: {
|
||||
expired_at_end: "",
|
||||
product: "",
|
||||
plan: "",
|
||||
account: "",
|
||||
status: "",
|
||||
},
|
||||
})
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
if (!filterValues || Object.values(filterValues).every(value => !value)) {
|
||||
return initialData
|
||||
}
|
||||
|
||||
return initialData.filter(record => {
|
||||
if (filterValues.expired_at_end) {
|
||||
return record.expiredAt <= filterValues.expired_at_end
|
||||
}
|
||||
|
||||
if (
|
||||
filterValues.product &&
|
||||
!record.product.includes(filterValues.product)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (filterValues.plan && !record.plan.includes(filterValues.plan)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (
|
||||
filterValues.account &&
|
||||
!record.account.includes(filterValues.account)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (filterValues.status && record.status !== filterValues.status) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
}, [filterValues])
|
||||
|
||||
const onSubmit = (values: FormValues) => {
|
||||
setStatus("load")
|
||||
setTimeout(() => {
|
||||
setFilterValues(values)
|
||||
setRowSelection({})
|
||||
setStatus("done")
|
||||
}, 200)
|
||||
}
|
||||
|
||||
const onReset = () => {
|
||||
reset()
|
||||
setFilterValues({})
|
||||
setRowSelection({})
|
||||
}
|
||||
|
||||
// 根据选中ID获取对应行数据
|
||||
const getSelectedRows = () => {
|
||||
const ids = Object.keys(rowSelection)
|
||||
return filteredData.filter(item => ids.includes(item.id))
|
||||
}
|
||||
|
||||
// 批量续费
|
||||
const handleRenew = () => {
|
||||
const rows = getSelectedRows()
|
||||
console.log("续费选中数据:", rows)
|
||||
}
|
||||
|
||||
// 单个退货
|
||||
const handleReturn = () => {
|
||||
const rows = getSelectedRows()
|
||||
if (rows.length !== 1) {
|
||||
toast.error("请选择一条数据进行退货")
|
||||
return
|
||||
}
|
||||
console.log("退货数据:", rows[0])
|
||||
}
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<div>
|
||||
<form className="bg-card rounded-lg" onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="flex items-center justify-center gap-4 bg-amber-200 px-3 py-3">
|
||||
<span className="text-orange-600 text-sm">
|
||||
为了给您带来更好的服务体验,请完善QQ号和微信号
|
||||
</span>
|
||||
<Link to="/">立即完善</Link>
|
||||
</div>
|
||||
<div className="flex flex-wrap p-4 items-end gap-4">
|
||||
<Controller
|
||||
name="expired_at_end"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field
|
||||
data-invalid={fieldState.invalid}
|
||||
className="w-48 flex-none"
|
||||
>
|
||||
<FieldLabel>到期时间</FieldLabel>
|
||||
<Input type="datetime-local" step="1" {...field} />
|
||||
<FieldError>{fieldState.error?.message}</FieldError>
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="product"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field
|
||||
data-invalid={fieldState.invalid}
|
||||
className="w-48 flex-none"
|
||||
>
|
||||
<FieldLabel>全部产品</FieldLabel>
|
||||
<Input {...field} placeholder="请输入产品名称" />
|
||||
<FieldError>{fieldState.error?.message}</FieldError>
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="plan"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field
|
||||
data-invalid={fieldState.invalid}
|
||||
className="w-48 flex-none"
|
||||
>
|
||||
<FieldLabel>全部套餐</FieldLabel>
|
||||
<Input {...field} placeholder="请输入套餐名称" />
|
||||
<FieldError>{fieldState.error?.message}</FieldError>
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="account"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field
|
||||
data-invalid={fieldState.invalid}
|
||||
className="w-40 flex-none"
|
||||
>
|
||||
<FieldLabel>账号</FieldLabel>
|
||||
<Input {...field} placeholder="请输入账号" />
|
||||
<FieldError>{fieldState.error?.message}</FieldError>
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button type="submit">查询</Button>
|
||||
<Button type="button" variant="outline" onClick={onReset}>
|
||||
重置
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="bg-card rounded-lg p-4">
|
||||
<div className="flex gap-4 items-center">
|
||||
<Button type="button" variant="outline" onClick={handleRenew}>
|
||||
续费
|
||||
</Button>
|
||||
<Button type="button" variant="outline" onClick={handleReturn}>
|
||||
退货
|
||||
</Button>
|
||||
<p className="text-orange-600 text-sm">
|
||||
温馨提示:批量续费可通过筛选和搜索功能精准定位账号
|
||||
</p>
|
||||
</div>
|
||||
<DataTable<ChannelRecord>
|
||||
data={filteredData}
|
||||
status={status}
|
||||
columns={[
|
||||
{ accessorKey: "startAt", header: "开通时间" },
|
||||
{ accessorKey: "product", header: "产品" },
|
||||
{ accessorKey: "plan", header: "套餐" },
|
||||
{ accessorKey: "account", header: "账号" },
|
||||
{ accessorKey: "password", header: "密码" },
|
||||
{ accessorKey: "ip", header: "IP地址" },
|
||||
{ accessorKey: "server", header: "服务器" },
|
||||
{ accessorKey: "connections", header: "连接数" },
|
||||
{ accessorKey: "expiredAt", header: "到期时间" },
|
||||
{ accessorKey: "remaining", header: "剩余时间" },
|
||||
{
|
||||
accessorKey: "online",
|
||||
header: "在线及拨线",
|
||||
cell: ({ row }) => (
|
||||
<Button size="sm" variant="outline">
|
||||
{row.original.online}
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
{ accessorKey: "status", header: "选择地区" },
|
||||
{
|
||||
accessorKey: "download",
|
||||
header: "软件下载",
|
||||
cell: ({ row }) => (
|
||||
<Button size="sm" variant="ghost">
|
||||
{row.original.download}
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
]}
|
||||
pagination={{
|
||||
page: 0,
|
||||
size: 10,
|
||||
total: filteredData.length,
|
||||
}}
|
||||
enableSelection
|
||||
getRowId={row => row.id}
|
||||
rowSelection={rowSelection}
|
||||
onSelectionChange={setRowSelection}
|
||||
/>
|
||||
</div>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
import { cn } from "@/lib/utils"
|
||||
import logoAvatar from "./assets/logo-avatar.svg"
|
||||
import { useLayout } from "./layout-context"
|
||||
import { AdminNavMenu } from "./nav-menu"
|
||||
|
||||
export function Shell(props: { children: ReactNode }) {
|
||||
const { navbar } = useLayout()
|
||||
@@ -35,7 +36,7 @@ export function Shell(props: { children: ReactNode }) {
|
||||
data-expand={navbar}
|
||||
className={cn(
|
||||
"transition-[grid-template-columns] duration-300 ease-in-out",
|
||||
"w-full h-full grid",
|
||||
"w-full h-full grid bg-card",
|
||||
"grid-rows-[64px_1fr]",
|
||||
"data-[expand=true]:grid-cols-[200px_1fr]",
|
||||
"data-[expand=false]:grid-cols-[0px_1fr]",
|
||||
@@ -57,7 +58,7 @@ export function Content(props: { children: ReactNode }) {
|
||||
"absolute inset-0 overflow-hidden",
|
||||
"mt-16",
|
||||
"md:ml-16",
|
||||
"lg:data-[expand=true]:ml-[200px]",
|
||||
"lg:data-[expand=true]:ml-50",
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
@@ -85,8 +86,8 @@ export function Mask() {
|
||||
export function Header() {
|
||||
const { navbar, toggleNavbar } = useLayout()
|
||||
return (
|
||||
<header className="flex-none h-16 overflow-hidden flex items-stretch">
|
||||
<div className="flex-auto flex items-center gap-2">
|
||||
<header className="flex-none h-16 flex items-stretch">
|
||||
<div className="flex-none flex items-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-9 ml-4 md:ml-0"
|
||||
@@ -96,13 +97,15 @@ export function Header() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<AdminNavMenu />
|
||||
|
||||
<div className="flex-none flex items-center justify-end pr-4 max-md:hidden gap-3">
|
||||
<Link
|
||||
{/* <Link
|
||||
to="/"
|
||||
className="flex-none h-16 flex items-center justify-center text-sm"
|
||||
>
|
||||
返回首页
|
||||
</Link>
|
||||
</Link> */}
|
||||
<UserCenter />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -92,7 +92,7 @@ export default function DashboardPage() {
|
||||
const [editDialogOpen, setEditDialogOpen] = useState(false)
|
||||
const handleSaveInfo = (data: any) => {
|
||||
console.log("保存的信息:", data)
|
||||
// 这里调用你的 API 保存数据
|
||||
// 这里调 API 保存数据
|
||||
}
|
||||
return (
|
||||
<Page
|
||||
|
||||
@@ -32,17 +32,17 @@ export default function Effects(props: { children?: ReactNode }) {
|
||||
|
||||
setNavbar(md.matches)
|
||||
|
||||
sm.addEventListener("change", (e) => {
|
||||
sm.addEventListener("change", e => {
|
||||
breakpoints.sm = e.matches
|
||||
})
|
||||
md.addEventListener("change", (e) => {
|
||||
md.addEventListener("change", e => {
|
||||
breakpoints.md = e.matches
|
||||
setNavbar(e.matches)
|
||||
})
|
||||
lg.addEventListener("change", (e) => {
|
||||
lg.addEventListener("change", e => {
|
||||
breakpoints.lg = e.matches
|
||||
})
|
||||
xl.addEventListener("change", (e) => {
|
||||
xl.addEventListener("change", e => {
|
||||
breakpoints.xl = e.matches
|
||||
})
|
||||
|
||||
|
||||
@@ -9,11 +9,11 @@ export default function AdminLayout() {
|
||||
<Effects />
|
||||
<div className="relative h-dvh overflow-hidden">
|
||||
<Shell>
|
||||
<div className="col-start-1 row-start-1 row-span-2 bg-card overflow-hidden relative z-20">
|
||||
<div className="col-start-1 row-start-1 row-span-2 bg-card overflow-hidden relative z-20 flex">
|
||||
<Navbar />
|
||||
</div>
|
||||
|
||||
<div className="col-start-2 row-start-1 bg-card overflow-hidden relative z-20">
|
||||
<div className="col-start-2 row-start-1 bg-card relative z-20">
|
||||
<Header />
|
||||
</div>
|
||||
|
||||
|
||||
252
src/admin/nav-menu.tsx
Normal file
252
src/admin/nav-menu.tsx
Normal file
@@ -0,0 +1,252 @@
|
||||
import { useCallback, useRef, useState } from "react"
|
||||
import { createPortal } from "react-dom"
|
||||
import { NavLink, useNavigate } from "react-router-dom"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export function AdminNavMenu() {
|
||||
return (
|
||||
<nav className="flex-auto flex items-center justify-center gap-1 max-lg:hidden">
|
||||
<AdminNavItem to="/">首页</AdminNavItem>
|
||||
<AdminMenuItem text="产品购买">
|
||||
<ProductDropdown />
|
||||
</AdminMenuItem>
|
||||
<AdminNavItem to="/ipline">IP线路表</AdminNavItem>
|
||||
<AdminNavItem to="/softDownload">软件下载</AdminNavItem>
|
||||
<AdminNavItem to="/help">帮助/教程</AdminNavItem>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
function AdminNavItem({
|
||||
to,
|
||||
children,
|
||||
}: {
|
||||
to: string
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<NavLink
|
||||
to={to}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
"h-10 flex items-center px-3 text-sm rounded-md transition-colors duration-200",
|
||||
isActive
|
||||
? "text-blue-600 bg-blue-50"
|
||||
: "text-slate-600 hover:text-blue-600 hover:bg-gray-100",
|
||||
)
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</NavLink>
|
||||
)
|
||||
}
|
||||
|
||||
function AdminMenuItem({
|
||||
text,
|
||||
children,
|
||||
}: {
|
||||
text: string
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [pos, setPos] = useState({ top: 0, left: 0 })
|
||||
const triggerRef = useRef<HTMLDivElement>(null)
|
||||
const timeoutRef = useRef<number | null>(null)
|
||||
|
||||
const updatePosition = useCallback(() => {
|
||||
if (triggerRef.current) {
|
||||
const rect = triggerRef.current.getBoundingClientRect()
|
||||
setPos({ top: rect.bottom + 8, left: rect.left + rect.width / 2 })
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current)
|
||||
timeoutRef.current = null
|
||||
}
|
||||
updatePosition()
|
||||
setIsOpen(true)
|
||||
}
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
setIsOpen(false)
|
||||
}, 150)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={triggerRef}
|
||||
className="relative h-full flex items-center"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<button
|
||||
className={cn(
|
||||
"h-10 flex items-center gap-1 px-3 text-sm rounded-md transition-colors duration-200 pointer-events-none",
|
||||
isOpen
|
||||
? "text-blue-600 bg-blue-50"
|
||||
: "text-slate-600 group-hover:text-blue-600 group-hover:bg-gray-100",
|
||||
)}
|
||||
>
|
||||
<span>{text}</span>
|
||||
<svg
|
||||
className={cn(
|
||||
"w-3 h-3 transition-transform duration-200",
|
||||
isOpen && "rotate-180",
|
||||
)}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{createPortal(
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={-1}
|
||||
className={cn(
|
||||
"fixed transition-all duration-200 z-[9999]",
|
||||
isOpen
|
||||
? "opacity-100 visible translate-y-0"
|
||||
: "opacity-0 invisible -translate-y-2 pointer-events-none",
|
||||
)}
|
||||
style={{
|
||||
top: pos.top,
|
||||
left: pos.left,
|
||||
transform: "translateX(-50%)",
|
||||
}}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<div className="bg-white rounded-xl shadow-2xl border border-gray-100/80 p-4 w-[560px]">
|
||||
{children}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ProductDropdown() {
|
||||
const navigate = useNavigate()
|
||||
|
||||
const handleClick = (path: string) => {
|
||||
navigate(path)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<ProductCard
|
||||
title="动静态IP"
|
||||
desc="千万级动态短效IP,自动去重,高并发采集"
|
||||
tag="热门"
|
||||
onClick={() => handleClick("/product/short")}
|
||||
/>
|
||||
<ProductCard
|
||||
title="新HTTP/S5"
|
||||
desc="IP可用时长从数小时到365天全覆盖"
|
||||
tag="稳定"
|
||||
onClick={() => handleClick("/product/long")}
|
||||
/>
|
||||
<ProductCard
|
||||
title="HTTP/S5"
|
||||
desc="海量全球住宅优质IP,4G/5G真实手机IP"
|
||||
tag="全球"
|
||||
onClick={() => handleClick("/product/global")}
|
||||
/>
|
||||
<ProductCard
|
||||
title="软路由成本价"
|
||||
desc="云端自动切换IP,更易于调用与开发"
|
||||
onClick={() => handleClick("/product/tunnel")}
|
||||
/>
|
||||
<ProductCard
|
||||
title="动态Vps"
|
||||
desc="IP独享,高带宽,灵活控制存活周期"
|
||||
onClick={() => handleClick("/product/exclusive")}
|
||||
/>
|
||||
<ProductCard
|
||||
title="静态独享IP"
|
||||
desc="纯净IP,更符合跨境卖家需求的云主机"
|
||||
onClick={() => handleClick("/product/static")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center flex-wrap gap-2 pt-2 border-t border-gray-100">
|
||||
<span className="text-xs font-medium text-gray-400">热门推荐:</span>
|
||||
<button
|
||||
onClick={() => handleClick("/product/short")}
|
||||
className="text-xs text-blue-600 hover:underline whitespace-nowrap"
|
||||
>
|
||||
短效代理
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleClick("/product/global")}
|
||||
className="text-xs text-blue-600 hover:underline whitespace-nowrap"
|
||||
>
|
||||
海外代理
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleClick("/product/tunnel")}
|
||||
className="text-xs text-blue-600 hover:underline whitespace-nowrap"
|
||||
>
|
||||
隧道代理
|
||||
</button>
|
||||
<span className="text-xs text-gray-300">|</span>
|
||||
<button
|
||||
onClick={() => handleClick("/custom")}
|
||||
className="text-xs text-orange-500 hover:underline font-medium whitespace-nowrap"
|
||||
>
|
||||
业务定制 →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ProductCard({
|
||||
title,
|
||||
desc,
|
||||
tag,
|
||||
onClick,
|
||||
}: {
|
||||
title: string
|
||||
desc: string
|
||||
tag?: string
|
||||
onClick: () => void
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="group flex items-start gap-2 p-2 rounded-lg hover:bg-gray-50 transition-all cursor-pointer border border-transparent hover:border-blue-200 text-left w-full"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<span className="font-medium text-gray-800 group-hover:text-blue-600 transition-colors text-xs whitespace-nowrap">
|
||||
{title}
|
||||
</span>
|
||||
{tag && (
|
||||
<span className="text-[10px] bg-blue-100 text-blue-600 px-1.5 py-0.5 rounded-full shrink-0">
|
||||
{tag}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[11px] text-gray-500 mt-0.5 leading-relaxed line-clamp-2">
|
||||
{desc}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user