chore(init): 初始化解忧商行项目并完成 Phase 0 基座
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
import { useEffect } from 'react'
|
||||
import { App as AntApp, ConfigProvider } from 'antd'
|
||||
import zhCN from 'antd/locale/zh_CN'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
|
||||
import { AppRouter } from '@/app/AppRouter'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { jieyouTheme } from '@/theme'
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: 15_000,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
function SessionBootstrap() {
|
||||
const hydrate = useSessionStore(state => state.hydrate)
|
||||
const token = useSessionStore(state => state.token)
|
||||
const loadMe = useSessionStore(state => state.loadMe)
|
||||
|
||||
useEffect(() => {
|
||||
hydrate()
|
||||
}, [hydrate])
|
||||
|
||||
useEffect(() => {
|
||||
if (token) loadMe().catch(() => undefined)
|
||||
}, [token, loadMe])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<ConfigProvider theme={jieyouTheme} locale={zhCN}>
|
||||
<AntApp>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<SessionBootstrap />
|
||||
<AppRouter />
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
</AntApp>
|
||||
</ConfigProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Route, Routes } from 'react-router-dom'
|
||||
|
||||
import { AdminPlaceholderPage } from '@/features/admin/AdminPlaceholderPage'
|
||||
import { LoginPage } from '@/features/auth/LoginPage'
|
||||
import { ListingListPage } from '@/features/listings/ListingListPage'
|
||||
import { NotFoundPage } from '@/features/NotFoundPage'
|
||||
import { StorefrontLayout } from '@/layouts/StorefrontLayout'
|
||||
|
||||
export function AppRouter() {
|
||||
const adminBase = import.meta.env.VITE_ADMIN_BASE_PATH || '/console-jy8k3m'
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route element={<StorefrontLayout />}>
|
||||
<Route index element={<ListingListPage />} />
|
||||
</Route>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path={`${adminBase}/*`} element={<AdminPlaceholderPage />} />
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 5.4 KiB |
@@ -0,0 +1,17 @@
|
||||
import logo from '@/assets/brand/logo-jieyou.png'
|
||||
|
||||
interface BrandLogoProps {
|
||||
height?: number
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function BrandLogo({ height = 44, className }: BrandLogoProps) {
|
||||
return (
|
||||
<img
|
||||
src={logo}
|
||||
alt="解忧商行 JIEYOU TRADING HOUSE"
|
||||
className={className}
|
||||
style={{ height, width: 'auto', display: 'block', borderRadius: 4 }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Button, Dropdown, Layout, Space, Typography } from 'antd'
|
||||
import { Link, Outlet, useNavigate } from 'react-router-dom'
|
||||
import { UserOutlined } from '@ant-design/icons'
|
||||
|
||||
import { BrandLogo } from '@/components/BrandLogo'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
|
||||
const { Header, Content, Footer } = Layout
|
||||
|
||||
export function StorefrontLayout() {
|
||||
const navigate = useNavigate()
|
||||
const user = useSessionStore(state => state.user)
|
||||
const logout = useSessionStore(state => state.logout)
|
||||
|
||||
return (
|
||||
<Layout className="site-shell">
|
||||
<Header className="site-header">
|
||||
<div className="site-header__inner">
|
||||
<Link to="/" className="site-brand" aria-label="解忧商行首页">
|
||||
<BrandLogo height={42} />
|
||||
</Link>
|
||||
|
||||
<nav className="site-nav" aria-label="主导航">
|
||||
<Link to="/">首页</Link>
|
||||
<a href="#listings">商品</a>
|
||||
<a href="#process">流程</a>
|
||||
<a href="#rules">规则</a>
|
||||
</nav>
|
||||
|
||||
<Space size={12}>
|
||||
{user ? (
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
{ key: 'phone', label: `已登录:${user.phone}`, disabled: true },
|
||||
{
|
||||
key: 'logout',
|
||||
label: '退出登录',
|
||||
onClick: async () => {
|
||||
await logout()
|
||||
navigate('/')
|
||||
},
|
||||
},
|
||||
],
|
||||
}}
|
||||
>
|
||||
<Button icon={<UserOutlined />}>{user.nickname || '我的商行'}</Button>
|
||||
</Dropdown>
|
||||
) : (
|
||||
<Button type="primary" onClick={() => navigate('/login')}>
|
||||
登录 / 注册
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
</Header>
|
||||
|
||||
<Content className="site-content">
|
||||
<Outlet />
|
||||
</Content>
|
||||
|
||||
<Footer className="site-footer">
|
||||
<div className="site-footer__inner">
|
||||
<div>
|
||||
<Typography.Text strong>解忧商行</Typography.Text>
|
||||
<Typography.Text type="secondary"> · JIEYOU TRADING HOUSE</Typography.Text>
|
||||
<div className="site-footer__muted">租得明白,还得安心。</div>
|
||||
</div>
|
||||
<div className="site-footer__muted">
|
||||
© 2026 解忧商行 · 备案信息与客服信息待配置
|
||||
</div>
|
||||
</div>
|
||||
</Footer>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import 'antd/dist/reset.css'
|
||||
import 'dayjs/locale/zh-cn'
|
||||
|
||||
import App from '@/App'
|
||||
import '@/styles/global.css'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
)
|
||||
@@ -0,0 +1,118 @@
|
||||
import axios, { AxiosError, type InternalAxiosRequestConfig } from 'axios'
|
||||
import { message } from 'antd'
|
||||
|
||||
import {
|
||||
clearAuthSession,
|
||||
getAccessToken,
|
||||
getLoginPath,
|
||||
getRefreshToken,
|
||||
setAuthSession,
|
||||
} from '@/shared/auth/storage'
|
||||
import type { ApiResponse, TokenPair } from '@/shared/api/types'
|
||||
|
||||
type RetryConfig = InternalAxiosRequestConfig & {
|
||||
_retry?: boolean
|
||||
skipErrorHandler?: boolean
|
||||
}
|
||||
|
||||
export const apiClient = axios.create({
|
||||
baseURL: '/api',
|
||||
timeout: 30000,
|
||||
withCredentials: true,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
|
||||
export function unwrapData<T>(request: Promise<{ data: ApiResponse<T> }>) {
|
||||
return request.then(response => response.data.data)
|
||||
}
|
||||
|
||||
function getSiteCode() {
|
||||
try {
|
||||
return sessionStorage.getItem('jieyou_site_code') || ''
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
function isAuthEntryRequest(url = '') {
|
||||
return (
|
||||
url.includes('/auth/password/login') ||
|
||||
url.includes('/auth/password/register') ||
|
||||
url.includes('/auth/sms/login') ||
|
||||
url.includes('/auth/refresh')
|
||||
)
|
||||
}
|
||||
|
||||
function isLoginPage() {
|
||||
return window.location.pathname === '/login' || window.location.pathname === '/m/login'
|
||||
}
|
||||
|
||||
function redirectToLogin() {
|
||||
if (isLoginPage()) return
|
||||
const current = window.location.pathname + window.location.search
|
||||
window.location.assign(`${getLoginPath()}?redirect=${encodeURIComponent(current)}`)
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
const payload = error.response?.data as { message?: string } | undefined
|
||||
return payload?.message || error.message || '网络连接异常,请稍后重试'
|
||||
}
|
||||
return error instanceof Error ? error.message : '网络连接异常,请稍后重试'
|
||||
}
|
||||
|
||||
let refreshPromise: Promise<TokenPair> | null = null
|
||||
|
||||
async function refreshAccessToken(): Promise<TokenPair> {
|
||||
const refreshToken = getRefreshToken()
|
||||
if (!refreshToken) throw new Error('登录已失效,请重新登录')
|
||||
const { data } = await axios.post<ApiResponse<TokenPair>>(
|
||||
'/api/auth/refresh',
|
||||
{ refresh_token: refreshToken },
|
||||
{ timeout: 10000, withCredentials: true }
|
||||
)
|
||||
setAuthSession(data.data)
|
||||
return data.data
|
||||
}
|
||||
|
||||
apiClient.interceptors.request.use(config => {
|
||||
const token = getAccessToken()
|
||||
if (token) config.headers.Authorization = `Bearer ${token}`
|
||||
|
||||
const siteCode = getSiteCode()
|
||||
if (siteCode && !config.headers['X-Distribution-Site-Code']) {
|
||||
config.headers['X-Distribution-Site-Code'] = siteCode
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
apiClient.interceptors.response.use(
|
||||
response => response,
|
||||
async (error: AxiosError) => {
|
||||
const config = error.config as RetryConfig | undefined
|
||||
const status = error.response?.status
|
||||
const url = config?.url || ''
|
||||
|
||||
if (!config || status !== 401 || config._retry || isAuthEntryRequest(url)) {
|
||||
if (!config?.skipErrorHandler && !isLoginPage()) {
|
||||
message.error(errorMessage(error))
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
config._retry = true
|
||||
try {
|
||||
refreshPromise ??= refreshAccessToken().finally(() => {
|
||||
refreshPromise = null
|
||||
})
|
||||
const tokens = await refreshPromise
|
||||
config.headers.Authorization = `Bearer ${tokens.access_token}`
|
||||
return apiClient(config)
|
||||
} catch (refreshError) {
|
||||
clearAuthSession()
|
||||
message.error('登录已失效,请重新登录')
|
||||
redirectToLogin()
|
||||
return Promise.reject(refreshError)
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,63 @@
|
||||
export interface ApiResponse<T> {
|
||||
code: string
|
||||
message: string
|
||||
data: T
|
||||
}
|
||||
|
||||
export interface AuthUser {
|
||||
id: number
|
||||
phone: string
|
||||
nickname: string
|
||||
avatar_url: string
|
||||
realname_status: string
|
||||
risk_status?: string
|
||||
credit_score?: number
|
||||
status?: string
|
||||
}
|
||||
|
||||
export interface TokenPair {
|
||||
access_token: string
|
||||
refresh_token: string
|
||||
token_type: string
|
||||
expires_in: number
|
||||
}
|
||||
|
||||
export interface LoginData {
|
||||
user: AuthUser
|
||||
tokens: TokenPair
|
||||
}
|
||||
|
||||
export interface Listing {
|
||||
id: number
|
||||
listing_no: string
|
||||
account_id: number
|
||||
owner_id: number
|
||||
owner_nickname?: string
|
||||
title: string
|
||||
description: string
|
||||
game_name: string
|
||||
server_region: string
|
||||
login_platform: string
|
||||
rank_level: string
|
||||
haf_coin_amount: number
|
||||
asset_summary?: Record<string, unknown>
|
||||
screenshot_urls: string[]
|
||||
cover_url: string
|
||||
price_cent: number
|
||||
deposit_amount_cent: number
|
||||
is_accelerated_sale?: boolean
|
||||
in_transaction: boolean
|
||||
status: string
|
||||
review_status: string
|
||||
review_reason: string
|
||||
published_at?: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface PublicListingPage {
|
||||
items: Listing[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { AuthUser, TokenPair } from '@/shared/api/types'
|
||||
|
||||
const ACCESS_TOKEN_KEY = 'access_token'
|
||||
const REFRESH_TOKEN_KEY = 'refresh_token'
|
||||
const USER_KEY = 'user_profile'
|
||||
|
||||
export function getAccessToken() {
|
||||
return localStorage.getItem(ACCESS_TOKEN_KEY) || ''
|
||||
}
|
||||
|
||||
export function getRefreshToken() {
|
||||
return localStorage.getItem(REFRESH_TOKEN_KEY) || ''
|
||||
}
|
||||
|
||||
export function setAuthSession(tokens: TokenPair, user?: AuthUser) {
|
||||
localStorage.setItem(ACCESS_TOKEN_KEY, tokens.access_token)
|
||||
localStorage.setItem(REFRESH_TOKEN_KEY, tokens.refresh_token)
|
||||
if (user) localStorage.setItem(USER_KEY, JSON.stringify(user))
|
||||
}
|
||||
|
||||
export function setAuthUser(user: AuthUser) {
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(user))
|
||||
}
|
||||
|
||||
export function getAuthUser(): AuthUser | null {
|
||||
const raw = localStorage.getItem(USER_KEY)
|
||||
if (!raw) return null
|
||||
try {
|
||||
return JSON.parse(raw) as AuthUser
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function clearAuthSession() {
|
||||
localStorage.removeItem(ACCESS_TOKEN_KEY)
|
||||
localStorage.removeItem(REFRESH_TOKEN_KEY)
|
||||
localStorage.removeItem(USER_KEY)
|
||||
}
|
||||
|
||||
export function getLoginPath() {
|
||||
return window.location.pathname.startsWith('/m') ? '/m/login' : '/login'
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { create } from 'zustand'
|
||||
|
||||
import { unwrapData } from '@/shared/api/client'
|
||||
import { apiClient } from '@/shared/api/client'
|
||||
import type { ApiResponse, AuthUser, LoginData } from '@/shared/api/types'
|
||||
import {
|
||||
clearAuthSession,
|
||||
getAccessToken,
|
||||
getAuthUser,
|
||||
setAuthSession,
|
||||
setAuthUser,
|
||||
} from '@/shared/auth/storage'
|
||||
|
||||
interface SessionState {
|
||||
user: AuthUser | null
|
||||
token: string
|
||||
hydrated: boolean
|
||||
login: (phone: string, password: string) => Promise<void>
|
||||
logout: () => Promise<void>
|
||||
hydrate: () => void
|
||||
loadMe: () => Promise<void>
|
||||
}
|
||||
|
||||
export const useSessionStore = create<SessionState>((set, get) => ({
|
||||
user: null,
|
||||
token: '',
|
||||
hydrated: false,
|
||||
|
||||
hydrate() {
|
||||
set({
|
||||
user: getAuthUser(),
|
||||
token: getAccessToken(),
|
||||
hydrated: true,
|
||||
})
|
||||
},
|
||||
|
||||
async login(phone, password) {
|
||||
const data = await unwrapData<LoginData>(
|
||||
apiClient.post<ApiResponse<LoginData>>('/auth/password/login', { phone, password })
|
||||
)
|
||||
setAuthSession(data.tokens, data.user)
|
||||
set({ user: data.user, token: data.tokens.access_token, hydrated: true })
|
||||
},
|
||||
|
||||
async logout() {
|
||||
try {
|
||||
await apiClient.post('/auth/logout', undefined, {
|
||||
skipErrorHandler: true,
|
||||
skipAuthRefresh: true,
|
||||
} as never)
|
||||
} catch {
|
||||
// 本地退出优先,服务端 token 失效不影响清理。
|
||||
}
|
||||
clearAuthSession()
|
||||
set({ user: null, token: '', hydrated: true })
|
||||
},
|
||||
|
||||
async loadMe() {
|
||||
if (!get().token) return
|
||||
const user = await unwrapData<AuthUser>(apiClient.get<ApiResponse<AuthUser>>('/me'))
|
||||
setAuthUser(user)
|
||||
set({ user })
|
||||
},
|
||||
}))
|
||||
@@ -0,0 +1,815 @@
|
||||
:root {
|
||||
--brand-ink: #1e4d42;
|
||||
--brand-ink-deep: #153a32;
|
||||
--brand-seal: #a64332;
|
||||
--brand-seal-line: #ecd0b1;
|
||||
--brand-brass: #b08a4a;
|
||||
--paper: #f6f2e8;
|
||||
--paper-warm: #fffdf8;
|
||||
--ink: #1c2521;
|
||||
--ink-secondary: #5e6b64;
|
||||
--ink-muted: #8a948e;
|
||||
--line: #e4ded2;
|
||||
--sage: #6f857c;
|
||||
--success: #2f7d5c;
|
||||
--warning: #c17a2b;
|
||||
--danger: #a64332;
|
||||
--info: #3e6b7a;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
background: var(--paper);
|
||||
color: var(--ink);
|
||||
font-family:
|
||||
'Noto Sans SC', 'Source Han Sans SC', 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
font-family: 'Noto Serif SC', 'Source Han Serif SC', 'Songti SC', serif !important;
|
||||
font-weight: 600 !important;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.site-shell {
|
||||
min-height: 100vh;
|
||||
background: var(--paper);
|
||||
}
|
||||
|
||||
.site-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
height: 72px;
|
||||
padding: 0;
|
||||
background: rgba(255, 253, 248, 0.94);
|
||||
border-bottom: 1px solid var(--line);
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
.site-header__inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
max-width: 1280px;
|
||||
height: 100%;
|
||||
margin: 0 auto;
|
||||
padding: 0 24px;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.site-brand {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.site-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 30px;
|
||||
margin-right: auto;
|
||||
color: var(--ink-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.site-nav a {
|
||||
position: relative;
|
||||
padding: 6px 0;
|
||||
transition: color 160ms ease;
|
||||
}
|
||||
|
||||
.site-nav a:hover {
|
||||
color: var(--brand-ink);
|
||||
}
|
||||
|
||||
.site-nav a::after {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
height: 1px;
|
||||
background: var(--brand-seal);
|
||||
content: '';
|
||||
opacity: 0;
|
||||
transform: scaleX(0.6);
|
||||
transition:
|
||||
opacity 160ms ease,
|
||||
transform 160ms ease;
|
||||
}
|
||||
|
||||
.site-nav a:hover::after {
|
||||
opacity: 1;
|
||||
transform: scaleX(1);
|
||||
}
|
||||
|
||||
.site-content {
|
||||
min-height: calc(100vh - 72px - 140px);
|
||||
background: var(--paper);
|
||||
}
|
||||
|
||||
.site-footer {
|
||||
padding: 0;
|
||||
background: var(--paper-warm);
|
||||
border-top: 1px solid var(--line);
|
||||
color: var(--ink-secondary);
|
||||
}
|
||||
|
||||
.site-footer__inner {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 34px 24px;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.site-footer__muted {
|
||||
margin-top: 8px;
|
||||
color: var(--ink-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Home hero */
|
||||
.home-hero {
|
||||
background: var(--brand-ink);
|
||||
color: var(--paper-warm);
|
||||
}
|
||||
|
||||
.home-hero__inner {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 7fr) minmax(0, 5fr);
|
||||
gap: 48px;
|
||||
align-items: center;
|
||||
max-width: 1280px;
|
||||
min-height: 520px;
|
||||
margin: 0 auto;
|
||||
padding: 76px 24px 68px;
|
||||
}
|
||||
|
||||
.home-hero__eyebrow {
|
||||
color: var(--brand-seal-line);
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.home-hero__title {
|
||||
margin: 18px 0 20px !important;
|
||||
color: #f0f4f2 !important;
|
||||
font-size: clamp(42px, 5vw, 68px) !important;
|
||||
line-height: 1.16 !important;
|
||||
}
|
||||
|
||||
.home-hero__lead {
|
||||
max-width: 560px;
|
||||
margin-bottom: 30px !important;
|
||||
color: #d8e2dd;
|
||||
font-size: 16px;
|
||||
line-height: 1.9;
|
||||
}
|
||||
|
||||
.home-hero__search {
|
||||
width: 420px;
|
||||
max-width: calc(100vw - 48px);
|
||||
}
|
||||
|
||||
.home-hero__search .ant-input-affix-wrapper {
|
||||
background: var(--paper-warm);
|
||||
}
|
||||
|
||||
.home-hero__visual {
|
||||
position: relative;
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
.hero-ticket {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 280px;
|
||||
padding: 22px;
|
||||
border: 1px solid rgba(30, 77, 66, 0.18);
|
||||
border-radius: 6px;
|
||||
background: var(--paper-warm);
|
||||
box-shadow: 0 1px 2px rgba(21, 58, 50, 0.08), 0 22px 48px rgba(21, 58, 50, 0.2);
|
||||
color: var(--brand-ink);
|
||||
}
|
||||
|
||||
.hero-ticket span {
|
||||
color: var(--ink-muted);
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.hero-ticket strong {
|
||||
font-family: 'Noto Serif SC', 'Songti SC', serif;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.hero-ticket small {
|
||||
color: var(--ink-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.hero-ticket--back {
|
||||
top: 8px;
|
||||
right: 0;
|
||||
transform: rotate(7deg);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.hero-ticket--main {
|
||||
top: 58px;
|
||||
left: 8px;
|
||||
transform: rotate(-3deg);
|
||||
}
|
||||
|
||||
.hero-ticket--front {
|
||||
right: 10px;
|
||||
bottom: 0;
|
||||
transform: rotate(4deg);
|
||||
}
|
||||
|
||||
.hero-ticket__seal {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
border: 2px solid var(--brand-seal);
|
||||
color: var(--brand-seal);
|
||||
font-family: 'Noto Serif SC', 'Songti SC', serif;
|
||||
font-size: 13px;
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
|
||||
/* Trust strip */
|
||||
.trust-strip {
|
||||
background: var(--paper-warm);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.trust-strip__inner {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 22px 24px;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.trust-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
color: var(--brand-ink);
|
||||
}
|
||||
|
||||
.trust-item > .anticon {
|
||||
margin-top: 2px;
|
||||
color: var(--brand-seal);
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.trust-item strong,
|
||||
.trust-item span {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.trust-item strong {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.trust-item span {
|
||||
margin-top: 4px;
|
||||
color: var(--ink-muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Sections */
|
||||
.section-block {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 72px 24px;
|
||||
}
|
||||
|
||||
.section-block--paper {
|
||||
max-width: none;
|
||||
background: var(--paper-warm);
|
||||
border-top: 1px solid var(--line);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.section-block--paper > .section-heading,
|
||||
.section-block--paper > .process-grid {
|
||||
max-width: 1280px;
|
||||
margin-right: auto;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.section-heading {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 28px;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.section-heading .ant-typography {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.section-eyebrow {
|
||||
color: var(--brand-seal);
|
||||
font-size: 11px !important;
|
||||
letter-spacing: 0.18em;
|
||||
}
|
||||
|
||||
.section-heading h2 {
|
||||
margin: 6px 0 8px !important;
|
||||
color: var(--brand-ink) !important;
|
||||
font-size: 32px !important;
|
||||
}
|
||||
|
||||
/* Listing cards */
|
||||
.listing-card {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
border-color: var(--line);
|
||||
background: var(--paper-warm);
|
||||
transition:
|
||||
border-color 160ms ease,
|
||||
transform 160ms ease;
|
||||
}
|
||||
|
||||
.listing-card:hover {
|
||||
border-color: var(--sage);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.listing-cover {
|
||||
aspect-ratio: 4 / 3;
|
||||
overflow: hidden;
|
||||
background: #eef1ea;
|
||||
}
|
||||
|
||||
.listing-cover img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.listing-cover--empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
background:
|
||||
linear-gradient(0deg, rgba(30, 77, 66, 0.04), rgba(30, 77, 66, 0.04)),
|
||||
var(--paper);
|
||||
color: var(--brand-ink);
|
||||
font-family: 'Noto Serif SC', 'Songti SC', serif;
|
||||
letter-spacing: 0.12em;
|
||||
}
|
||||
|
||||
.listing-card__head,
|
||||
.listing-card__price-row,
|
||||
.listing-card__meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.listing-card__no {
|
||||
color: var(--ink-muted);
|
||||
font-family: 'Inter', monospace;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.listing-card__title {
|
||||
margin: 12px 0 10px !important;
|
||||
color: var(--ink) !important;
|
||||
font-size: 17px !important;
|
||||
}
|
||||
|
||||
.listing-card__tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.listing-card__tags .ant-tag {
|
||||
margin: 0;
|
||||
border-radius: 2px;
|
||||
background: #eef2ec;
|
||||
color: var(--brand-ink);
|
||||
}
|
||||
|
||||
.listing-card__meta {
|
||||
margin-top: 14px;
|
||||
color: var(--ink-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.listing-card__price-row {
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.listing-card__price {
|
||||
color: var(--brand-seal);
|
||||
font-family: 'Inter', 'DIN Alternate', sans-serif;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.listing-card__unit,
|
||||
.listing-card__deposit {
|
||||
color: var(--ink-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.empty-panel {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 64px 24px;
|
||||
border: 1px dashed var(--line);
|
||||
border-radius: 8px;
|
||||
background: var(--paper-warm);
|
||||
}
|
||||
|
||||
.section-pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 32px;
|
||||
}
|
||||
|
||||
/* Process */
|
||||
.process-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: 0;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.process-step {
|
||||
padding: 18px 20px 8px 0;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.process-step__no {
|
||||
display: block;
|
||||
margin-bottom: 18px;
|
||||
color: var(--brand-seal);
|
||||
font-family: 'Inter', monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.process-step strong {
|
||||
color: var(--brand-ink);
|
||||
font-family: 'Noto Serif SC', 'Songti SC', serif;
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.process-step p {
|
||||
margin: 10px 0 0;
|
||||
color: var(--ink-muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
/* Rules */
|
||||
.rules-panel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 32px;
|
||||
padding: 36px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: var(--paper-warm);
|
||||
}
|
||||
|
||||
.rules-panel .ant-typography {
|
||||
max-width: 720px;
|
||||
}
|
||||
|
||||
/* Login */
|
||||
.login-page {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.05fr) minmax(0, 0.95fr);
|
||||
min-height: 100vh;
|
||||
background: var(--paper);
|
||||
}
|
||||
|
||||
.login-brand-panel {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: var(--brand-ink);
|
||||
color: var(--paper-warm);
|
||||
}
|
||||
|
||||
.login-brand-panel::after {
|
||||
position: absolute;
|
||||
right: -80px;
|
||||
bottom: -120px;
|
||||
width: 360px;
|
||||
height: 360px;
|
||||
border: 1px solid rgba(236, 208, 177, 0.22);
|
||||
border-radius: 50%;
|
||||
content: '';
|
||||
}
|
||||
|
||||
.login-brand-panel__inner {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
max-width: 560px;
|
||||
min-height: 100vh;
|
||||
margin: 0 auto;
|
||||
padding: 64px;
|
||||
}
|
||||
|
||||
.login-brand__logo {
|
||||
align-self: flex-start;
|
||||
margin-bottom: 48px;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
background: var(--paper-warm);
|
||||
}
|
||||
|
||||
.login-brand__title {
|
||||
margin: 0 0 22px !important;
|
||||
color: #f0f4f2 !important;
|
||||
font-size: clamp(42px, 5vw, 64px) !important;
|
||||
line-height: 1.18 !important;
|
||||
}
|
||||
|
||||
.login-brand__copy {
|
||||
max-width: 430px;
|
||||
color: #d8e2dd !important;
|
||||
font-size: 15px;
|
||||
line-height: 1.9;
|
||||
}
|
||||
|
||||
.login-brand__points {
|
||||
margin-top: 24px;
|
||||
color: #d8e2dd;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.login-brand__points .anticon {
|
||||
margin-right: 8px;
|
||||
color: var(--brand-seal-line);
|
||||
}
|
||||
|
||||
.login-brand__seal {
|
||||
position: absolute;
|
||||
right: 36px;
|
||||
bottom: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border: 2px solid var(--brand-seal);
|
||||
color: var(--brand-seal-line);
|
||||
font-family: 'Noto Serif SC', 'Songti SC', serif;
|
||||
font-size: 18px;
|
||||
letter-spacing: 0.12em;
|
||||
transform: rotate(-6deg);
|
||||
}
|
||||
|
||||
.login-form-panel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px 24px;
|
||||
}
|
||||
|
||||
.login-form-card {
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
padding: 36px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: var(--paper-warm);
|
||||
box-shadow: 0 1px 2px rgba(29, 36, 33, 0.04), 0 20px 60px rgba(29, 36, 33, 0.06);
|
||||
}
|
||||
|
||||
.login-form-card__top {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.login-form-card__top .ant-typography {
|
||||
display: block;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.login-form-card__top h2 {
|
||||
margin-bottom: 8px !important;
|
||||
color: var(--brand-ink) !important;
|
||||
}
|
||||
|
||||
.login-alert {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.login-dev-hint {
|
||||
margin-top: 18px;
|
||||
padding: 10px 12px;
|
||||
border: 1px dashed var(--line);
|
||||
border-radius: 4px;
|
||||
color: var(--ink-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.login-form-card__footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 24px;
|
||||
padding-top: 18px;
|
||||
border-top: 1px solid var(--line);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.login-form-card__footer a {
|
||||
color: var(--brand-ink);
|
||||
}
|
||||
|
||||
/* Admin placeholder */
|
||||
.admin-placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding: 32px;
|
||||
background: var(--paper);
|
||||
}
|
||||
|
||||
.admin-placeholder__card {
|
||||
width: min(760px, 100%);
|
||||
padding: 40px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: var(--paper-warm);
|
||||
}
|
||||
|
||||
.admin-placeholder__card h2 {
|
||||
margin: 22px 0 10px !important;
|
||||
color: var(--brand-ink) !important;
|
||||
}
|
||||
|
||||
.not-found {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
background: var(--paper);
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.home-hero__inner {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.home-hero__visual {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.process-grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
row-gap: 24px;
|
||||
}
|
||||
|
||||
.trust-strip__inner {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.site-nav {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.login-page {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.login-brand-panel__inner {
|
||||
min-height: auto;
|
||||
padding: 44px 28px 52px;
|
||||
}
|
||||
|
||||
.login-brand__logo {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.login-brand__seal {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.site-header {
|
||||
height: 64px;
|
||||
}
|
||||
|
||||
.site-header__inner {
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.site-header__inner > .ant-space .ant-btn {
|
||||
padding-inline: 10px;
|
||||
}
|
||||
|
||||
.home-hero__inner {
|
||||
min-height: auto;
|
||||
padding: 52px 16px 48px;
|
||||
}
|
||||
|
||||
.home-hero__title {
|
||||
font-size: 42px !important;
|
||||
}
|
||||
|
||||
.home-hero__search {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.trust-strip__inner,
|
||||
.section-block,
|
||||
.site-footer__inner {
|
||||
padding-right: 16px;
|
||||
padding-left: 16px;
|
||||
}
|
||||
|
||||
.trust-strip__inner {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.section-block {
|
||||
padding-top: 52px;
|
||||
padding-bottom: 52px;
|
||||
}
|
||||
|
||||
.section-heading {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.process-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.process-step {
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.rules-panel {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.login-form-panel {
|
||||
padding: 28px 16px;
|
||||
}
|
||||
|
||||
.login-form-card {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.site-footer__inner {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { ThemeConfig } from 'antd'
|
||||
|
||||
export const jieyouTheme: ThemeConfig = {
|
||||
token: {
|
||||
colorPrimary: '#1E4D42',
|
||||
colorInfo: '#3E6B7A',
|
||||
colorSuccess: '#2F7D5C',
|
||||
colorWarning: '#C17A2B',
|
||||
colorError: '#A64332',
|
||||
colorText: '#1C2521',
|
||||
colorTextSecondary: '#5E6B64',
|
||||
colorTextTertiary: '#8A948E',
|
||||
colorBgBase: '#FFFDF8',
|
||||
colorBgLayout: '#F6F2E8',
|
||||
colorBorder: '#E4DED2',
|
||||
borderRadius: 4,
|
||||
borderRadiusLG: 8,
|
||||
controlHeight: 40,
|
||||
fontFamily:
|
||||
'"Noto Sans SC", "Source Han Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif',
|
||||
},
|
||||
components: {
|
||||
Button: {
|
||||
borderRadius: 4,
|
||||
primaryShadow: 'none',
|
||||
defaultShadow: 'none',
|
||||
},
|
||||
Card: {
|
||||
borderRadiusLG: 8,
|
||||
boxShadowTertiary: '0 1px 2px rgba(29,36,33,.05)',
|
||||
},
|
||||
Table: {
|
||||
headerBg: '#F6F2E8',
|
||||
headerColor: '#5E6B64',
|
||||
rowHoverBg: '#FBF8F1',
|
||||
borderColor: '#E4DED2',
|
||||
},
|
||||
Menu: {
|
||||
itemSelectedBg: '#E6EEE9',
|
||||
itemSelectedColor: '#1E4D42',
|
||||
itemHoverBg: '#F1F5F0',
|
||||
},
|
||||
Modal: {
|
||||
borderRadiusLG: 12,
|
||||
},
|
||||
Drawer: {
|
||||
colorBgElevated: '#FFFDF8',
|
||||
},
|
||||
},
|
||||
}
|
||||
Vendored
+16
@@ -0,0 +1,16 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.png' {
|
||||
const src: string
|
||||
export default src
|
||||
}
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_ADMIN_BASE_PATH?: string
|
||||
readonly VITE_DISTRIBUTION_ADMIN_URL?: string
|
||||
readonly VITE_API_PROXY_TARGET?: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
Reference in New Issue
Block a user