chore(init): 初始化解忧商行项目并完成 Phase 0 基座
This commit is contained in:
@@ -0,0 +1,297 @@
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Empty,
|
||||
Input,
|
||||
Pagination,
|
||||
Row,
|
||||
Skeleton,
|
||||
Space,
|
||||
Tag,
|
||||
Typography,
|
||||
} from 'antd'
|
||||
import {
|
||||
ArrowRightOutlined,
|
||||
SafetyCertificateOutlined,
|
||||
SearchOutlined,
|
||||
WalletOutlined,
|
||||
} from '@ant-design/icons'
|
||||
|
||||
import { BrandLogo } from '@/components/BrandLogo'
|
||||
import { useListings } from '@/features/listings/api'
|
||||
import type { Listing } from '@/shared/api/types'
|
||||
|
||||
const PAGE_SIZE = 12
|
||||
|
||||
function money(cents: number) {
|
||||
return `¥${(cents / 100).toFixed(2)}`
|
||||
}
|
||||
|
||||
function ListingCard({ item }: { item: Listing }) {
|
||||
return (
|
||||
<Card className="listing-card" hoverable cover={<ListingCover item={item} />}>
|
||||
<div className="listing-card__head">
|
||||
<Typography.Text className="listing-card__no">{item.listing_no}</Typography.Text>
|
||||
<Tag color={item.in_transaction ? 'default' : 'green'} bordered={false}>
|
||||
{item.in_transaction ? '租赁中' : '可租'}
|
||||
</Tag>
|
||||
</div>
|
||||
<Typography.Title level={4} className="listing-card__title" ellipsis={{ rows: 2 }}>
|
||||
{item.title}
|
||||
</Typography.Title>
|
||||
<div className="listing-card__tags">
|
||||
<Tag bordered={false}>{item.server_region || '区服待定'}</Tag>
|
||||
<Tag bordered={false}>{item.login_platform || '登录方式待定'}</Tag>
|
||||
{item.rank_level ? <Tag bordered={false}>{item.rank_level}</Tag> : null}
|
||||
</div>
|
||||
<div className="listing-card__meta">
|
||||
<span>哈夫币 {item.haf_coin_amount.toLocaleString()}</span>
|
||||
<span>{item.owner_nickname || '商行认证号主'}</span>
|
||||
</div>
|
||||
<div className="listing-card__price-row">
|
||||
<div>
|
||||
<span className="listing-card__price">{money(item.price_cent)}</span>
|
||||
<span className="listing-card__unit"> / 单</span>
|
||||
</div>
|
||||
<span className="listing-card__deposit">押金 {money(item.deposit_amount_cent)}</span>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function ListingCover({ item }: { item: Listing }) {
|
||||
if (item.cover_url) {
|
||||
return (
|
||||
<div className="listing-cover">
|
||||
<img src={item.cover_url} alt={item.title} loading="lazy" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="listing-cover listing-cover--empty">
|
||||
<BrandLogo height={30} />
|
||||
<span>解忧柜选</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ListingSkeleton() {
|
||||
return (
|
||||
<Card className="listing-card">
|
||||
<Skeleton active paragraph={{ rows: 4 }} />
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export function ListingListPage() {
|
||||
const [keyword, setKeyword] = useState('')
|
||||
const [page, setPage] = useState(1)
|
||||
const { data, isPending, isError, refetch } = useListings({
|
||||
page,
|
||||
page_size: PAGE_SIZE,
|
||||
keyword,
|
||||
})
|
||||
|
||||
const submitSearch = (value: string) => {
|
||||
setKeyword(value.trim())
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="home-hero">
|
||||
<div className="home-hero__inner">
|
||||
<div className="home-hero__copy">
|
||||
<Typography.Text className="home-hero__eyebrow">
|
||||
解忧商行 · 账号租赁
|
||||
</Typography.Text>
|
||||
<Typography.Title className="brand-title home-hero__title">
|
||||
租得明白,
|
||||
<br />
|
||||
还得安心。
|
||||
</Typography.Title>
|
||||
<Typography.Paragraph className="home-hero__lead">
|
||||
实名审核、流程留痕、账单逐项列明。先看清规则,再决定要不要租。
|
||||
</Typography.Paragraph>
|
||||
<Space size={12} wrap>
|
||||
<Input.Search
|
||||
size="large"
|
||||
allowClear
|
||||
enterButton={
|
||||
<Button type="primary" icon={<SearchOutlined />}>
|
||||
找账号
|
||||
</Button>
|
||||
}
|
||||
placeholder="搜索区服、段位、装备或商品号"
|
||||
onSearch={submitSearch}
|
||||
className="home-hero__search"
|
||||
/>
|
||||
<Button size="large" onClick={() => document.querySelector('#listings')?.scrollIntoView()}>
|
||||
浏览柜选 <ArrowRightOutlined />
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<div className="home-hero__visual" aria-hidden="true">
|
||||
<div className="hero-ticket hero-ticket--back">
|
||||
<span>交接留痕</span>
|
||||
<strong>每一步都有记录</strong>
|
||||
</div>
|
||||
<div className="hero-ticket hero-ticket--main">
|
||||
<div className="hero-ticket__seal">解忧</div>
|
||||
<span>本周柜选</span>
|
||||
<strong>严选账号 · 逐项核验</strong>
|
||||
<small>费用透明 / 押金可退 / 争议可申诉</small>
|
||||
</div>
|
||||
<div className="hero-ticket hero-ticket--front">
|
||||
<span>账单</span>
|
||||
<strong>租金 + 押金 + 消耗</strong>
|
||||
<small>确认后再支付</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="trust-strip">
|
||||
<div className="trust-strip__inner">
|
||||
{[
|
||||
['实名审核', '发布与下单前完成身份核验'],
|
||||
['流程留痕', '交接、归还、结账全程有记录'],
|
||||
['账单透明', '租金、押金、消耗逐项列明'],
|
||||
['争议可申诉', '双方协商不成可进入仲裁'],
|
||||
].map(([title, desc], index) => (
|
||||
<div className="trust-item" key={title}>
|
||||
{index === 0 ? <SafetyCertificateOutlined /> : index === 2 ? <WalletOutlined /> : null}
|
||||
<div>
|
||||
<strong>{title}</strong>
|
||||
<span>{desc}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section-block" id="listings">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<Typography.Text className="section-eyebrow">CURATED ACCOUNTS</Typography.Text>
|
||||
<Typography.Title level={2} className="brand-title">
|
||||
本周柜选
|
||||
</Typography.Title>
|
||||
<Typography.Text type="secondary">
|
||||
按区服、段位和资产筛选,价格与押金直接写在柜签上。
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Typography.Text type="secondary">
|
||||
共 {data?.total ?? 0} 件 · 第 {page} 页
|
||||
</Typography.Text>
|
||||
</div>
|
||||
|
||||
{isError ? (
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
message="商品列表没有加载出来"
|
||||
description="后端服务可能暂时不可用,你的账号和订单不受影响。"
|
||||
action={
|
||||
<Button size="small" onClick={() => refetch()}>
|
||||
重试
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Row gutter={[16, 16]}>
|
||||
{isPending
|
||||
? Array.from({ length: 6 }).map((_, index) => (
|
||||
<Col xs={24} sm={12} lg={8} key={index}>
|
||||
<ListingSkeleton />
|
||||
</Col>
|
||||
))
|
||||
: data?.items.map(item => (
|
||||
<Col xs={24} sm={12} lg={8} key={item.id}>
|
||||
<ListingCard item={item} />
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
|
||||
{!isPending && !isError && data?.items.length === 0 ? (
|
||||
<div className="empty-panel">
|
||||
<Empty
|
||||
description={
|
||||
<span>
|
||||
这里还没有符合条件的商品。
|
||||
<br />
|
||||
换个关键词,或者稍后再来看看。
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<Button type="primary" onClick={() => submitSearch('')}>
|
||||
查看全部
|
||||
</Button>
|
||||
</Empty>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{data && data.total > PAGE_SIZE ? (
|
||||
<div className="section-pagination">
|
||||
<Pagination
|
||||
current={page}
|
||||
pageSize={PAGE_SIZE}
|
||||
total={data.total}
|
||||
showSizeChanger={false}
|
||||
onChange={setPage}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section className="section-block section-block--paper" id="process">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<Typography.Text className="section-eyebrow">HOW IT WORKS</Typography.Text>
|
||||
<Typography.Title level={2} className="brand-title">
|
||||
从挑选到结账,五步走完
|
||||
</Typography.Title>
|
||||
</div>
|
||||
</div>
|
||||
<div className="process-grid">
|
||||
{[
|
||||
['01', '挑选账号', '看区服、段位、资产和押金,确认商品编号。'],
|
||||
['02', '下单支付', '核对租金与押金,支付后进入待交接。'],
|
||||
['03', '完成交接', '号主提交交接信息,租客确认后开始租期。'],
|
||||
['04', '归还账号', '租客提交归还,号主确认后进入结账。'],
|
||||
['05', '核对账单', '双方确认或客服仲裁,完成后结算。'],
|
||||
].map(([no, title, desc]) => (
|
||||
<div className="process-step" key={no}>
|
||||
<span className="process-step__no">{no}</span>
|
||||
<strong>{title}</strong>
|
||||
<p>{desc}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section-block" id="rules">
|
||||
<div className="rules-panel">
|
||||
<div>
|
||||
<Typography.Text className="section-eyebrow">RULES, IN PLAIN WORDS</Typography.Text>
|
||||
<Typography.Title level={3} className="brand-title">
|
||||
先把规则说明白
|
||||
</Typography.Title>
|
||||
<Typography.Paragraph type="secondary">
|
||||
押金、租金、额外消耗和退款规则都会在订单里逐项列出。结账备注只是双方留言,
|
||||
不参与系统结算;真正参与结算的是系统账单。
|
||||
</Typography.Paragraph>
|
||||
</div>
|
||||
<Button size="large" onClick={() => document.querySelector('#listings')?.scrollIntoView()}>
|
||||
去看柜选
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
import { apiClient, unwrapData } from '@/shared/api/client'
|
||||
import type { ApiResponse, PublicListingPage } from '@/shared/api/types'
|
||||
|
||||
export interface ListingQuery {
|
||||
page?: number
|
||||
page_size?: number
|
||||
keyword?: string
|
||||
}
|
||||
|
||||
export function fetchListings(query: ListingQuery) {
|
||||
return unwrapData<Partial<PublicListingPage>>(
|
||||
apiClient.get<ApiResponse<Partial<PublicListingPage>>>('/listings', {
|
||||
params: Object.fromEntries(
|
||||
Object.entries(query).filter(([, value]) => value !== '' && value != null)
|
||||
),
|
||||
})
|
||||
).then(data => ({
|
||||
items: data.items ?? [],
|
||||
total: data.total ?? 0,
|
||||
page: data.page ?? query.page ?? 1,
|
||||
page_size: data.page_size ?? query.page_size ?? 12,
|
||||
}))
|
||||
}
|
||||
|
||||
export function useListings(query: ListingQuery) {
|
||||
return useQuery({
|
||||
queryKey: ['listings', query],
|
||||
queryFn: () => fetchListings(query),
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user