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
+50
View File
@@ -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>
)
}
+22
View File
@@ -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

+17
View File
@@ -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 }}
/>
)
}
+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),
})
}
+76
View File
@@ -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>
)
}
+13
View File
@@ -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>
)
+118
View File
@@ -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)
}
}
)
+63
View File
@@ -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
}
+43
View File
@@ -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'
}
+64
View File
@@ -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 })
},
}))
+815
View File
@@ -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;
}
}
+50
View File
@@ -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',
},
},
}
+16
View File
@@ -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
}