chore(init): 初始化解忧商行项目并完成 Phase 0 基座

This commit is contained in:
yml2213
2026-10-30 11:46:10 +08:00
commit 8d9481c251
689 changed files with 138703 additions and 0 deletions
+19
View File
@@ -0,0 +1,19 @@
import { Button, Result } from 'antd'
import { Link } from 'react-router-dom'
export function NotFoundPage() {
return (
<div className="not-found">
<Result
status="404"
title="这页没有找到"
subTitle="地址可能写错了,或者页面还在迁移中。"
extra={
<Link to="/">
<Button type="primary">回商行首页</Button>
</Link>
}
/>
</div>
)
}
@@ -0,0 +1,33 @@
import { Button, Result, Typography } from 'antd'
import { Link } from 'react-router-dom'
import { BrandLogo } from '@/components/BrandLogo'
export function AdminPlaceholderPage() {
const basePath = import.meta.env.VITE_ADMIN_BASE_PATH || '/console-jy8k3m'
return (
<main className="admin-placeholder">
<div className="admin-placeholder__card">
<BrandLogo height={46} />
<Typography.Title level={2} className="brand-title">
管理后台 · Phase 0
</Typography.Title>
<Typography.Paragraph type="secondary">
后台 Shell 已就位,当前路径为 <code>{basePath}</code>。订单、商品、财务、分销等 43
个页面将在后续阶段迁移;当前保留占位,避免误用未完成的权限与数据操作。
</Typography.Paragraph>
<Result
status="info"
title="后台功能尚未接入"
subTitle="Phase 0 只验证品牌、路由、认证基础设施与接口连通性。"
extra={
<Link to="/">
<Button type="primary">返回商行首页</Button>
</Link>
}
/>
</div>
</main>
)
}
+125
View File
@@ -0,0 +1,125 @@
import { useState } from 'react'
import { Alert, App, Button, Form, Input, Space, Typography } from 'antd'
import { CheckCircleFilled, LockOutlined, PhoneOutlined } from '@ant-design/icons'
import { Link, Navigate, useNavigate, useSearchParams } from 'react-router-dom'
import { BrandLogo } from '@/components/BrandLogo'
import { useSessionStore } from '@/stores/session'
interface LoginForm {
phone: string
password: string
}
export function LoginPage() {
const { message } = App.useApp()
const navigate = useNavigate()
const [searchParams] = useSearchParams()
const login = useSessionStore(state => state.login)
const token = useSessionStore(state => state.token)
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState('')
if (token) return <Navigate to="/" replace />
const onFinish = async (values: LoginForm) => {
setSubmitting(true)
setError('')
try {
await login(values.phone.trim(), values.password)
message.success('登录成功')
const redirect = searchParams.get('redirect')
navigate(redirect && redirect.startsWith('/') ? redirect : '/', { replace: true })
} catch (err) {
const text =
err instanceof Error && err.message ? err.message : '登录没有完成,请核对手机号和密码'
setError(text)
} finally {
setSubmitting(false)
}
}
return (
<main className="login-page">
<section className="login-brand-panel">
<div className="login-brand-panel__inner">
<div className="login-brand__logo">
<BrandLogo height={58} />
</div>
<Typography.Title level={1} className="brand-title login-brand__title">
租得明白
<br />
还得安心
</Typography.Title>
<Typography.Paragraph className="login-brand__copy">
解忧商行把费用、押金、交接和结算规则逐项写清楚。每一步都有记录,每一笔账都能核对。
</Typography.Paragraph>
<Space direction="vertical" size={10} className="login-brand__points">
{['实名审核后才能发布与下单', '交接、归还、结账全程留痕', '账单逐项列明,争议可申诉'].map(
item => (
<span key={item}>
<CheckCircleFilled /> {item}
</span>
)
)}
</Space>
<div className="login-brand__seal">解忧</div>
</div>
</section>
<section className="login-form-panel">
<div className="login-form-card">
<div className="login-form-card__top">
<BrandLogo height={38} />
<Typography.Title level={2} className="brand-title">
登录解忧商行
</Typography.Title>
<Typography.Text type="secondary">
使用手机号和密码进入。还没有账号?注册流程将在下一阶段接入。
</Typography.Text>
</div>
{error ? <Alert type="error" showIcon message={error} className="login-alert" /> : null}
<Form<LoginForm> layout="vertical" size="large" onFinish={onFinish} requiredMark={false}>
<Form.Item
label="手机号"
name="phone"
rules={[
{ required: true, message: '请输入手机号' },
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号' },
]}
>
<Input prefix={<PhoneOutlined />} placeholder="请输入手机号" autoComplete="tel" />
</Form.Item>
<Form.Item
label="密码"
name="password"
rules={[{ required: true, message: '请输入密码' }]}
>
<Input.Password
prefix={<LockOutlined />}
placeholder="请输入密码"
autoComplete="current-password"
/>
</Form.Item>
<Button type="primary" htmlType="submit" block loading={submitting}>
进入商行
</Button>
</Form>
{import.meta.env.DEV ? (
<div className="login-dev-hint">
开发测试账号:13800138000 / Jieyou123
</div>
) : null}
<div className="login-form-card__footer">
<Link to="/">先看看商品</Link>
<Typography.Text type="secondary">客服与备案信息待配置</Typography.Text>
</div>
</div>
</section>
</main>
)
}
@@ -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>
</>
)
}
+32
View File
@@ -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),
})
}