Initial commit: 云印享后台管理系统

This commit is contained in:
2026-03-29 18:23:02 +08:00
commit f685a8aeed
23346 changed files with 2302962 additions and 0 deletions

1
src/App.css Normal file
View File

@@ -0,0 +1 @@
/* App specific styles */

47
src/App.tsx Normal file
View File

@@ -0,0 +1,47 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import Login from './pages/Login'
import Layout from './components/Layout'
import Dashboard from './pages/Dashboard'
import UserManagement from './pages/UserManagement'
import OrderManagement from './pages/OrderManagement'
import FileManagement from './pages/FileManagement'
import PriceManagement from './pages/PriceManagement'
import PrintSettings from './pages/PrintSettings'
import Statistics from './pages/Statistics'
import './App.css'
function App() {
const isAuthenticated = () => {
return localStorage.getItem('admin_token') !== null
}
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
return isAuthenticated() ? <>{children}</> : <Navigate to="/login" replace />
}
return (
<BrowserRouter>
<Routes>
<Route path="/login" element={<Login />} />
<Route
path="/"
element={
<ProtectedRoute>
<Layout />
</ProtectedRoute>
}
>
<Route index element={<Dashboard />} />
<Route path="users" element={<UserManagement />} />
<Route path="orders" element={<OrderManagement />} />
<Route path="files" element={<FileManagement />} />
<Route path="prices" element={<PriceManagement />} />
<Route path="settings" element={<PrintSettings />} />
<Route path="statistics" element={<Statistics />} />
</Route>
</Routes>
</BrowserRouter>
)
}
export default App

95
src/api/admin.ts Normal file
View File

@@ -0,0 +1,95 @@
import request from './request'
import type {
AdminLoginRequest,
AdminLoginResponse,
UserListResponse,
OrderListResponse,
FileListResponse,
PriceListResponse,
CreatePriceRequest,
UpdatePriceRequest,
StatisticsData,
ApiResponse,
PrintSettings,
} from '@/types/api'
// 管理员认证
export const adminLogin = (data: AdminLoginRequest): Promise<ApiResponse<AdminLoginResponse>> => {
return request.post('/cloudprint.AdminService/Login', data)
}
export const adminLogout = (): Promise<ApiResponse<void>> => {
return request.post('/cloudprint.AdminService/Logout')
}
// 用户管理
export const getUserList = (params: { page?: number; page_size?: number; keyword?: string }): Promise<ApiResponse<UserListResponse>> => {
return request.post('/cloudprint.AdminService/GetUserList', params)
}
export const getUserDetail = (userId: string): Promise<ApiResponse<any>> => {
return request.post('/cloudprint.AdminService/GetUserDetail', { user_id: userId })
}
// 订单管理
export const getOrderList = (params: {
page?: number
page_size?: number
status?: string
start_date?: string
end_date?: string
}): Promise<ApiResponse<OrderListResponse>> => {
return request.post('/cloudprint.AdminService/GetOrderList', params)
}
export const getOrderDetail = (orderId: string): Promise<ApiResponse<any>> => {
return request.post('/cloudprint.AdminService/GetOrderDetail', { order_id: orderId })
}
export const updateOrderStatus = (orderId: string, status: string): Promise<ApiResponse<void>> => {
return request.post('/cloudprint.AdminService/UpdateOrderStatus', { order_id: orderId, status })
}
// 文件管理
export const getFileList = (params: { page?: number; page_size?: number; user_id?: string }): Promise<ApiResponse<FileListResponse>> => {
return request.post('/cloudprint.AdminService/GetFileList', params)
}
export const deleteFile = (fileId: string): Promise<ApiResponse<void>> => {
return request.post('/cloudprint.AdminService/DeleteFile', { file_id: fileId })
}
// 价目表管理
export const getPriceList = (): Promise<ApiResponse<PriceListResponse>> => {
return request.post('/cloudprint.AdminService/GetPriceList', {})
}
export const createPrice = (data: CreatePriceRequest): Promise<ApiResponse<void>> => {
return request.post('/cloudprint.AdminService/CreatePrice', data)
}
export const updatePrice = (data: UpdatePriceRequest): Promise<ApiResponse<void>> => {
return request.post('/cloudprint.AdminService/UpdatePrice', data)
}
export const deletePrice = (priceId: string): Promise<ApiResponse<void>> => {
return request.post('/cloudprint.AdminService/DeletePrice', { id: priceId })
}
// 打印设置
export const getPrintSettings = (): Promise<ApiResponse<PrintSettings[]>> => {
return request.post('/cloudprint.AdminService/GetPrintSettings', {})
}
export const updatePrintSettings = (data: Partial<PrintSettings>): Promise<ApiResponse<void>> => {
return request.post('/cloudprint.AdminService/UpdatePrintSettings', data)
}
// 数据统计
export const getStatistics = (params?: { start_date?: string; end_date?: string }): Promise<ApiResponse<StatisticsData>> => {
return request.post('/cloudprint.AdminService/GetStatistics', params || {})
}
export const getDailyStatistics = (date: string): Promise<ApiResponse<any>> => {
return request.post('/cloudprint.AdminService/GetDailyStatistics', { date })
}

65
src/api/request.ts Normal file
View File

@@ -0,0 +1,65 @@
import axios, { AxiosError, AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'
import { message } from 'antd'
// 创建 axios 实例
const request: AxiosInstance = axios.create({
baseURL: '/api',
timeout: 10000,
headers: {
'Content-Type': 'application/json',
},
})
// 请求拦截器
request.interceptors.request.use(
(config: AxiosRequestConfig) => {
const token = localStorage.getItem('admin_token')
if (token && config.headers) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
(error: AxiosError) => {
return Promise.reject(error)
}
)
// 响应拦截器
request.interceptors.response.use(
(response: AxiosResponse) => {
const { data } = response
if (data.code !== 0 && data.code !== 200) {
message.error(data.message || '请求失败')
return Promise.reject(new Error(data.message))
}
return data
},
(error: AxiosError) => {
const { response } = error
if (response) {
switch (response.status) {
case 401:
message.error('登录已过期,请重新登录')
localStorage.removeItem('admin_token')
window.location.href = '/login'
break
case 403:
message.error('没有权限执行此操作')
break
case 404:
message.error('请求的资源不存在')
break
case 500:
message.error('服务器错误')
break
default:
message.error(response.data?.message || '网络错误')
}
} else {
message.error('网络连接失败')
}
return Promise.reject(error)
}
)
export default request

View File

@@ -0,0 +1,104 @@
.admin-layout {
min-height: 100vh;
}
.admin-sider {
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.06);
z-index: 10;
}
.logo {
height: 64px;
display: flex;
align-items: center;
justify-content: center;
border-bottom: 1px solid #f0f0f0;
padding: 0 16px;
}
.logo-icon {
font-size: 28px;
color: #1890ff;
}
.logo-text {
margin-left: 12px;
font-size: 20px;
font-weight: 600;
color: #1890ff;
}
.admin-menu {
border-right: none;
padding: 8px;
}
.admin-menu .ant-menu-item {
border-radius: 6px;
margin: 4px 0;
}
.admin-menu .ant-menu-item-selected {
background: linear-gradient(135deg, #1890ff 0%, #36cfc9 100%);
}
.admin-menu .ant-menu-item-selected a,
.admin-menu .ant-menu-item-selected .anticon {
color: #fff;
}
.admin-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
z-index: 9;
}
.trigger-btn {
font-size: 16px;
}
.header-right {
display: flex;
align-items: center;
gap: 16px;
}
.notification-btn {
font-size: 16px;
}
.user-info {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
padding: 4px 8px;
border-radius: 4px;
transition: background 0.3s;
}
.user-info:hover {
background: #f5f5f5;
}
.username {
font-size: 14px;
color: #262626;
}
.admin-content {
margin: 24px;
padding: 24px;
min-height: 280px;
overflow: auto;
}
@media (max-width: 768px) {
.admin-content {
margin: 12px;
padding: 16px;
}
}

View File

@@ -0,0 +1,140 @@
import { useState } from 'react'
import { Layout as AntLayout, Menu, Button, Avatar, Dropdown, Badge, theme } from 'antd'
import {
DashboardOutlined,
UserOutlined,
FileTextOutlined,
FolderOutlined,
DollarOutlined,
PrinterOutlined,
BarChartOutlined,
LogoutOutlined,
BellOutlined,
MenuFoldOutlined,
MenuUnfoldOutlined,
} from '@ant-design/icons'
import { Outlet, useNavigate, useLocation } from 'react-router-dom'
import './index.css'
const { Header, Sider, Content } = AntLayout
const Layout = () => {
const [collapsed, setCollapsed] = useState(false)
const navigate = useNavigate()
const location = useLocation()
const {
token: { colorBgContainer, borderRadiusLG },
} = theme.useToken()
const menuItems = [
{
key: '/',
icon: <DashboardOutlined />,
label: '数据概览',
},
{
key: '/users',
icon: <UserOutlined />,
label: '用户管理',
},
{
key: '/orders',
icon: <FileTextOutlined />,
label: '订单管理',
},
{
key: '/files',
icon: <FolderOutlined />,
label: '文件管理',
},
{
key: '/prices',
icon: <DollarOutlined />,
label: '价目表管理',
},
{
key: '/settings',
icon: <PrinterOutlined />,
label: '打印设置',
},
{
key: '/statistics',
icon: <BarChartOutlined />,
label: '数据统计',
},
]
const handleMenuClick = (key: string) => {
navigate(key)
}
const handleLogout = () => {
localStorage.removeItem('admin_token')
navigate('/login')
}
const userMenuItems = [
{
key: 'logout',
icon: <LogoutOutlined />,
label: '退出登录',
onClick: handleLogout,
},
]
return (
<AntLayout className="admin-layout">
<Sider
trigger={null}
collapsible
collapsed={collapsed}
theme="light"
className="admin-sider"
>
<div className="logo">
<PrinterOutlined className="logo-icon" />
{!collapsed && <span className="logo-text"></span>}
</div>
<Menu
mode="inline"
selectedKeys={[location.pathname]}
items={menuItems}
onClick={({ key }) => handleMenuClick(key)}
className="admin-menu"
/>
</Sider>
<AntLayout>
<Header className="admin-header" style={{ background: colorBgContainer }}>
<Button
type="text"
icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
onClick={() => setCollapsed(!collapsed)}
className="trigger-btn"
/>
<div className="header-right">
<Badge count={5} size="small">
<Button type="text" icon={<BellOutlined />} className="notification-btn" />
</Badge>
<Dropdown menu={{ items: userMenuItems }} placement="bottomRight">
<div className="user-info">
<Avatar icon={<UserOutlined />} size="small" />
<span className="username"></span>
</div>
</Dropdown>
</div>
</Header>
<Content
className="admin-content"
style={{
background: colorBgContainer,
borderRadius: borderRadiusLG,
}}
>
<Outlet />
</Content>
</AntLayout>
</AntLayout>
)
}
export default Layout

48
src/index.css Normal file
View File

@@ -0,0 +1,48 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body, #root {
height: 100%;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
}
/* 自定义滚动条 */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: #f1f1f1;
}
::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
/* 页面过渡动画 */
.fade-enter {
opacity: 0;
}
.fade-enter-active {
opacity: 1;
transition: opacity 300ms ease-in;
}
.fade-exit {
opacity: 1;
}
.fade-exit-active {
opacity: 0;
transition: opacity 300ms ease-in;
}

28
src/main.tsx Normal file
View File

@@ -0,0 +1,28 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { ConfigProvider } from 'antd'
import zhCN from 'antd/locale/zh_CN'
// @ts-ignore
import App from './App'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<ConfigProvider
locale={zhCN}
theme={{
token: {
colorPrimary: '#1890ff',
colorSuccess: '#52c41a',
colorWarning: '#faad14',
colorError: '#ff4d4f',
colorInfo: '#1890ff',
borderRadius: 6,
wireframe: false,
},
}}
>
<App />
</ConfigProvider>
</React.StrictMode>,
)

View File

@@ -0,0 +1,113 @@
.dashboard-container {
padding: 0;
}
.dashboard-loading {
display: flex;
justify-content: center;
align-items: center;
min-height: 400px;
}
.dashboard-header {
margin-bottom: 24px;
}
.dashboard-header h4 {
margin-bottom: 4px;
}
.stat-row {
margin-bottom: 16px;
}
.stat-card {
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
transition: all 0.3s ease;
}
.stat-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
}
.stat-content {
display: flex;
align-items: center;
gap: 16px;
}
.stat-icon {
width: 56px;
height: 56px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
}
.stat-info {
flex: 1;
}
.stat-title {
font-size: 14px;
display: block;
margin-bottom: 4px;
}
.today-row {
margin-bottom: 16px;
}
.today-card {
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
background: linear-gradient(135deg, #fafafa 0%, #f5f5f5 100%);
}
.today-content {
display: flex;
align-items: center;
gap: 20px;
}
.today-icon {
width: 64px;
height: 64px;
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
font-size: 28px;
}
.today-info {
flex: 1;
}
.today-title {
font-size: 16px;
display: block;
margin-bottom: 8px;
}
.chart-row {
margin-top: 16px;
}
.chart-card {
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.chart-placeholder {
height: 300px;
display: flex;
align-items: center;
justify-content: center;
background: #fafafa;
border-radius: 8px;
}

View File

@@ -0,0 +1,174 @@
import { useEffect, useState } from 'react'
import { Row, Col, Card, Statistic, Typography, Spin } from 'antd'
import {
UserOutlined,
FileTextOutlined,
FolderOutlined,
DollarOutlined,
RiseOutlined,
ShoppingOutlined,
} from '@ant-design/icons'
import { getStatistics } from '@/api/admin'
import type { StatisticsData } from '@/types/api'
import './index.css'
const { Title, Text } = Typography
const Dashboard = () => {
const [loading, setLoading] = useState(true)
const [stats, setStats] = useState<StatisticsData | null>(null)
useEffect(() => {
fetchStatistics()
}, [])
const fetchStatistics = async () => {
try {
const res = await getStatistics()
if (res.data) {
setStats(res.data)
}
} catch (error) {
console.error('Failed to fetch statistics:', error)
} finally {
setLoading(false)
}
}
const statCards = [
{
title: '总用户数',
value: stats?.total_users || 0,
icon: <UserOutlined />,
color: '#1890ff',
bgColor: '#e6f7ff',
},
{
title: '总订单数',
value: stats?.total_orders || 0,
icon: <FileTextOutlined />,
color: '#52c41a',
bgColor: '#f6ffed',
},
{
title: '总文件数',
value: stats?.total_files || 0,
icon: <FolderOutlined />,
color: '#722ed1',
bgColor: '#f9f0ff',
},
{
title: '总收入',
value: stats?.total_revenue || 0,
prefix: '¥',
icon: <DollarOutlined />,
color: '#fa8c16',
bgColor: '#fff7e6',
precision: 2,
},
]
const todayCards = [
{
title: '今日订单',
value: stats?.today_orders || 0,
icon: <ShoppingOutlined />,
color: '#13c2c2',
bgColor: '#e6fffb',
},
{
title: '今日收入',
value: stats?.today_revenue || 0,
prefix: '¥',
icon: <RiseOutlined />,
color: '#eb2f96',
bgColor: '#fff0f6',
precision: 2,
},
]
if (loading) {
return (
<div className="dashboard-loading">
<Spin size="large" />
</div>
)
}
return (
<div className="dashboard-container">
<div className="dashboard-header">
<Title level={4}></Title>
<Text type="secondary">使</Text>
</div>
<Row gutter={[16, 16]} className="stat-row">
{statCards.map((card, index) => (
<Col xs={24} sm={12} lg={6} key={index}>
<Card className="stat-card" bordered={false}>
<div className="stat-content">
<div
className="stat-icon"
style={{ backgroundColor: card.bgColor, color: card.color }}
>
{card.icon}
</div>
<div className="stat-info">
<Text type="secondary" className="stat-title">
{card.title}
</Text>
<Statistic
value={card.value}
prefix={card.prefix}
precision={card.precision || 0}
valueStyle={{ color: card.color, fontSize: '24px', fontWeight: 600 }}
/>
</div>
</div>
</Card>
</Col>
))}
</Row>
<Row gutter={[16, 16]} className="today-row">
{todayCards.map((card, index) => (
<Col xs={24} sm={12} key={index}>
<Card className="today-card" bordered={false}>
<div className="today-content">
<div
className="today-icon"
style={{ backgroundColor: card.bgColor, color: card.color }}
>
{card.icon}
</div>
<div className="today-info">
<Text type="secondary" className="today-title">
{card.title}
</Text>
<Statistic
value={card.value}
prefix={card.prefix}
precision={card.precision || 0}
valueStyle={{ color: card.color, fontSize: '32px', fontWeight: 600 }}
/>
</div>
</div>
</Card>
</Col>
))}
</Row>
<Row gutter={[16, 16]} className="chart-row">
<Col span={24}>
<Card title="最近7天订单趋势" bordered={false} className="chart-card">
<div className="chart-placeholder">
<Text type="secondary">...</Text>
</div>
</Card>
</Col>
</Row>
</div>
)
}
export default Dashboard

View File

@@ -0,0 +1,71 @@
.file-management {
padding: 0;
}
.page-header {
margin-bottom: 24px;
}
.page-header h4 {
margin: 0;
}
.search-card {
margin-bottom: 16px;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.table-card {
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.file-name {
font-weight: 500;
color: #262626;
}
.file-id {
font-size: 12px;
}
.file-preview {
padding: 16px 0;
}
.preview-header {
display: flex;
align-items: center;
gap: 16px;
padding-bottom: 16px;
border-bottom: 1px solid #f0f0f0;
margin-bottom: 16px;
}
.preview-info {
flex: 1;
}
.preview-name {
font-size: 16px;
font-weight: 500;
margin-bottom: 8px;
}
.preview-content {
display: flex;
justify-content: center;
align-items: center;
min-height: 300px;
background: #fafafa;
border-radius: 8px;
}
.preview-placeholder {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 40px;
}

View File

@@ -0,0 +1,316 @@
import { useEffect, useState } from 'react'
import {
Table,
Card,
Input,
Button,
Tag,
Space,
Typography,
message,
Modal,
Popconfirm,
} from 'antd'
import {
SearchOutlined,
EyeOutlined,
DeleteOutlined,
FilePdfOutlined,
FileWordOutlined,
FileExcelOutlined,
FileImageOutlined,
FileOutlined,
DownloadOutlined,
} from '@ant-design/icons'
import { getFileList, deleteFile } from '@/api/admin'
import type { File } from '@/types/api'
import './index.css'
const { Title, Text } = Typography
const FileManagement = () => {
const [loading, setLoading] = useState(false)
const [files, setFiles] = useState<File[]>([])
const [total, setTotal] = useState(0)
const [currentPage, setCurrentPage] = useState(1)
const [pageSize, setPageSize] = useState(10)
const [searchKeyword, setSearchKeyword] = useState('')
const [previewModalVisible, setPreviewModalVisible] = useState(false)
const [selectedFile, setSelectedFile] = useState<File | null>(null)
const fetchFiles = async (page = currentPage, size = pageSize) => {
setLoading(true)
try {
const res = await getFileList({
page,
page_size: size,
})
if (res.data) {
setFiles(res.data.files)
setTotal(res.data.total)
}
} catch (error) {
console.error('Failed to fetch files:', error)
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchFiles()
}, [])
const handleSearch = () => {
setCurrentPage(1)
fetchFiles(1, pageSize)
}
const handleDelete = async (fileId: string) => {
try {
await deleteFile(fileId)
message.success('删除成功')
fetchFiles()
} catch (error) {
message.error('删除失败')
}
}
const handlePreview = (file: File) => {
setSelectedFile(file)
setPreviewModalVisible(true)
}
const formatFileSize = (bytes: number) => {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
const getFileIcon = (filename: string) => {
const ext = filename.split('.').pop()?.toLowerCase()
switch (ext) {
case 'pdf':
return <FilePdfOutlined style={{ color: '#ff4d4f', fontSize: 24 }} />
case 'doc':
case 'docx':
return <FileWordOutlined style={{ color: '#1890ff', fontSize: 24 }} />
case 'xls':
case 'xlsx':
return <FileExcelOutlined style={{ color: '#52c41a', fontSize: 24 }} />
case 'jpg':
case 'jpeg':
case 'png':
case 'gif':
return <FileImageOutlined style={{ color: '#722ed1', fontSize: 24 }} />
default:
return <FileOutlined style={{ color: '#8c8c8c', fontSize: 24 }} />
}
}
const getFileTypeTag = (filename: string) => {
const ext = filename.split('.').pop()?.toUpperCase()
const colorMap: Record<string, string> = {
PDF: 'red',
DOC: 'blue',
DOCX: 'blue',
XLS: 'green',
XLSX: 'green',
JPG: 'purple',
JPEG: 'purple',
PNG: 'purple',
}
return <Tag color={colorMap[ext || ''] || 'default'}>{ext}</Tag>
}
const columns = [
{
title: '文件名',
dataIndex: 'name',
key: 'name',
render: (text: string, record: File) => (
<Space>
{getFileIcon(text)}
<div>
<div className="file-name">{text}</div>
<Text type="secondary" className="file-id">
ID: {record.id.substring(0, 8)}...
</Text>
</div>
</Space>
),
},
{
title: '类型',
dataIndex: 'name',
key: 'type',
width: 100,
render: (text: string) => getFileTypeTag(text),
},
{
title: '大小',
dataIndex: 'size',
key: 'size',
width: 120,
render: (size: number) => formatFileSize(size),
},
{
title: '页数',
dataIndex: 'page_count',
key: 'page_count',
width: 100,
render: (count: number) => count ? `${count}` : '-',
},
{
title: '用户ID',
dataIndex: 'user_id',
key: 'user_id',
render: (text: string) => <Tag>{text?.substring(0, 8)}...</Tag>,
},
{
title: '上传时间',
dataIndex: 'created_at',
key: 'created_at',
render: (text: string) => new Date(text).toLocaleString('zh-CN'),
},
{
title: '操作',
key: 'action',
width: 180,
render: (_: unknown, record: File) => (
<Space>
<Button
type="text"
icon={<EyeOutlined />}
onClick={() => handlePreview(record)}
>
</Button>
<Button
type="text"
icon={<DownloadOutlined />}
href={record.url}
target="_blank"
>
</Button>
<Popconfirm
title="确认删除"
description="确定要删除这个文件吗?此操作不可恢复。"
onConfirm={() => handleDelete(record.id)}
okText="确定"
cancelText="取消"
okButtonProps={{ danger: true }}
>
<Button type="text" danger icon={<DeleteOutlined />}>
</Button>
</Popconfirm>
</Space>
),
},
]
return (
<div className="file-management">
<div className="page-header">
<Title level={4}></Title>
</div>
<Card className="search-card" bordered={false}>
<Space>
<Input
placeholder="搜索文件名"
value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
onPressEnter={handleSearch}
prefix={<SearchOutlined />}
style={{ width: 300 }}
allowClear
/>
<Button type="primary" icon={<SearchOutlined />} onClick={handleSearch}>
</Button>
</Space>
</Card>
<Card className="table-card" bordered={false}>
<Table
columns={columns}
dataSource={files}
rowKey="id"
loading={loading}
pagination={{
current: currentPage,
pageSize: pageSize,
total: total,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total) => `${total} 条记录`,
onChange: (page, size) => {
setCurrentPage(page)
setPageSize(size || 10)
fetchFiles(page, size || 10)
},
}}
/>
</Card>
<Modal
title="文件预览"
open={previewModalVisible}
onCancel={() => setPreviewModalVisible(false)}
footer={[
<Button key="close" onClick={() => setPreviewModalVisible(false)}>
</Button>,
<Button
key="download"
type="primary"
icon={<DownloadOutlined />}
href={selectedFile?.url}
target="_blank"
>
</Button>,
]}
width={800}
>
{selectedFile && (
<div className="file-preview">
<div className="preview-header">
{getFileIcon(selectedFile.name)}
<div className="preview-info">
<div className="preview-name">{selectedFile.name}</div>
<Space>
<Tag>{formatFileSize(selectedFile.size)}</Tag>
{selectedFile.page_count && (
<Tag color="blue">{selectedFile.page_count} </Tag>
)}
</Space>
</div>
</div>
<div className="preview-content">
{selectedFile.url?.match(/\.(jpg|jpeg|png|gif)$/i) ? (
<img
src={selectedFile.url}
alt={selectedFile.name}
style={{ maxWidth: '100%', maxHeight: '400px' }}
/>
) : (
<div className="preview-placeholder">
<FileOutlined style={{ fontSize: 64, color: '#d9d9d9' }} />
<Text type="secondary">线</Text>
<Text type="secondary"></Text>
</div>
)}
</div>
</div>
)}
</Modal>
</div>
)
}
export default FileManagement

162
src/pages/Login/index.css Normal file
View File

@@ -0,0 +1,162 @@
.login-container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
position: relative;
overflow: hidden;
}
.login-background {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
overflow: hidden;
}
.gradient-orb {
position: absolute;
border-radius: 50%;
filter: blur(80px);
opacity: 0.5;
animation: float 20s infinite ease-in-out;
}
.orb-1 {
width: 400px;
height: 400px;
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
top: -100px;
left: -100px;
animation-delay: 0s;
}
.orb-2 {
width: 300px;
height: 300px;
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
bottom: -50px;
right: -50px;
animation-delay: -5s;
}
.orb-3 {
width: 250px;
height: 250px;
background: linear-gradient(135deg, #43e97b 0%, #38f9d7 100%);
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
animation-delay: -10s;
}
@keyframes float {
0%, 100% {
transform: translate(0, 0) scale(1);
}
25% {
transform: translate(30px, -30px) scale(1.1);
}
50% {
transform: translate(-20px, 20px) scale(0.9);
}
75% {
transform: translate(20px, 30px) scale(1.05);
}
}
.login-card {
width: 420px;
padding: 40px;
border-radius: 20px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
backdrop-filter: blur(10px);
background: rgba(255, 255, 255, 0.95);
position: relative;
z-index: 1;
}
.login-header {
text-align: center;
margin-bottom: 40px;
}
.logo-container {
width: 80px;
height: 80px;
background: linear-gradient(135deg, #1890ff 0%, #36cfc9 100%);
border-radius: 20px;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 20px;
box-shadow: 0 8px 24px rgba(24, 144, 255, 0.3);
}
.login-logo {
font-size: 40px;
color: #fff;
}
.login-title {
margin-bottom: 8px !important;
background: linear-gradient(135deg, #1890ff 0%, #36cfc9 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.login-subtitle {
font-size: 14px;
}
.login-form {
margin-top: 30px;
}
.login-input {
border-radius: 10px;
height: 48px;
}
.input-icon {
color: #bfbfbf;
font-size: 16px;
}
.login-button {
height: 48px;
border-radius: 10px;
font-size: 16px;
font-weight: 500;
background: linear-gradient(135deg, #1890ff 0%, #36cfc9 100%);
border: none;
box-shadow: 0 4px 12px rgba(24, 144, 255, 0.4);
transition: all 0.3s ease;
}
.login-button:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(24, 144, 255, 0.5);
}
.login-footer {
text-align: center;
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid #f0f0f0;
}
.copyright {
font-size: 12px;
}
@media (max-width: 480px) {
.login-card {
width: 90%;
padding: 30px 20px;
}
}

96
src/pages/Login/index.tsx Normal file
View File

@@ -0,0 +1,96 @@
import { useState } from 'react'
import { Form, Input, Button, Card, message, Typography } from 'antd'
import { UserOutlined, LockOutlined, PrinterOutlined } from '@ant-design/icons'
import { useNavigate } from 'react-router-dom'
import { adminLogin } from '@/api/admin'
import './index.css'
const { Title, Text } = Typography
const Login = () => {
const [loading, setLoading] = useState(false)
const navigate = useNavigate()
const onFinish = async (values: { username: string; password: string }) => {
setLoading(true)
try {
const res = await adminLogin(values)
if (res.data && res.data.token) {
localStorage.setItem('admin_token', res.data.token)
message.success('登录成功')
navigate('/')
}
} catch (error) {
console.error('Login error:', error)
} finally {
setLoading(false)
}
}
return (
<div className="login-container">
<div className="login-background">
<div className="gradient-orb orb-1"></div>
<div className="gradient-orb orb-2"></div>
<div className="gradient-orb orb-3"></div>
</div>
<Card className="login-card" bordered={false}>
<div className="login-header">
<div className="logo-container">
<PrinterOutlined className="login-logo" />
</div>
<Title level={3} className="login-title"></Title>
<Text type="secondary" className="login-subtitle"></Text>
</div>
<Form
name="login"
onFinish={onFinish}
autoComplete="off"
size="large"
className="login-form"
>
<Form.Item
name="username"
rules={[{ required: true, message: '请输入用户名' }]}
>
<Input
prefix={<UserOutlined className="input-icon" />}
placeholder="用户名"
className="login-input"
/>
</Form.Item>
<Form.Item
name="password"
rules={[{ required: true, message: '请输入密码' }]}
>
<Input.Password
prefix={<LockOutlined className="input-icon" />}
placeholder="密码"
className="login-input"
/>
</Form.Item>
<Form.Item>
<Button
type="primary"
htmlType="submit"
loading={loading}
block
className="login-button"
>
</Button>
</Form.Item>
</Form>
<div className="login-footer">
<Text type="secondary" className="copyright">
© 2024
</Text>
</div>
</Card>
</div>
)
}
export default Login

View File

@@ -0,0 +1,26 @@
.order-management {
padding: 0;
}
.page-header {
margin-bottom: 24px;
}
.page-header h4 {
margin: 0;
}
.search-card {
margin-bottom: 16px;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.table-card {
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.table-card .ant-table {
font-size: 14px;
}

View File

@@ -0,0 +1,324 @@
import { useEffect, useState } from 'react'
import {
Table,
Card,
Input,
Button,
Tag,
Space,
Typography,
message,
Modal,
Descriptions,
DatePicker,
Select,
Dropdown,
} from 'antd'
import {
SearchOutlined,
EyeOutlined,
MoreOutlined,
CheckCircleOutlined,
CloseCircleOutlined,
PrinterOutlined,
FileTextOutlined,
} from '@ant-design/icons'
import { getOrderList, getOrderDetail, updateOrderStatus } from '@/api/admin'
import type { Order } from '@/types/api'
import './index.css'
const { Title, Text } = Typography
const { RangePicker } = DatePicker
const { Option } = Select
const OrderManagement = () => {
const [loading, setLoading] = useState(false)
const [orders, setOrders] = useState<Order[]>([])
const [total, setTotal] = useState(0)
const [currentPage, setCurrentPage] = useState(1)
const [pageSize, setPageSize] = useState(10)
const [searchKeyword, setSearchKeyword] = useState('')
const [statusFilter, setStatusFilter] = useState<string>('')
const [dateRange, setDateRange] = useState<[string, string] | null>(null)
const [selectedOrder, setSelectedOrder] = useState<Order | null>(null)
const [detailModalVisible, setDetailModalVisible] = useState(false)
const fetchOrders = async (page = currentPage, size = pageSize) => {
setLoading(true)
try {
const params: any = {
page,
page_size: size,
}
if (statusFilter) {
params.status = statusFilter
}
if (dateRange) {
params.start_date = dateRange[0]
params.end_date = dateRange[1]
}
const res = await getOrderList(params)
if (res.data) {
setOrders(res.data.orders)
setTotal(res.data.total)
}
} catch (error) {
console.error('Failed to fetch orders:', error)
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchOrders()
}, [])
const handleSearch = () => {
setCurrentPage(1)
fetchOrders(1, pageSize)
}
const handleViewDetail = async (order: Order) => {
try {
const res = await getOrderDetail(order.id)
if (res.data) {
setSelectedOrder(res.data)
setDetailModalVisible(true)
}
} catch (error) {
message.error('获取订单详情失败')
}
}
const handleUpdateStatus = async (orderId: string, status: string) => {
try {
await updateOrderStatus(orderId, status)
message.success('状态更新成功')
fetchOrders()
} catch (error) {
message.error('状态更新失败')
}
}
const getStatusTag = (status: string) => {
const statusMap: Record<string, { color: string; text: string; icon: React.ReactNode }> = {
pending: { color: 'orange', text: '待支付', icon: <FileTextOutlined /> },
paid: { color: 'blue', text: '已支付', icon: <CheckCircleOutlined /> },
printing: { color: 'cyan', text: '打印中', icon: <PrinterOutlined /> },
completed: { color: 'green', text: '已完成', icon: <CheckCircleOutlined /> },
cancelled: { color: 'red', text: '已取消', icon: <CloseCircleOutlined /> },
}
const config = statusMap[status] || { color: 'default', text: status, icon: null }
return (
<Tag color={config.color} icon={config.icon}>
{config.text}
</Tag>
)
}
const getActionItems = (record: Order) => {
const items = []
if (record.status === 'paid') {
items.push({
key: 'printing',
label: '开始打印',
icon: <PrinterOutlined />,
onClick: () => handleUpdateStatus(record.id, 'printing'),
})
}
if (record.status === 'printing') {
items.push({
key: 'completed',
label: '完成订单',
icon: <CheckCircleOutlined />,
onClick: () => handleUpdateStatus(record.id, 'completed'),
})
}
if (record.status === 'pending') {
items.push({
key: 'cancelled',
label: '取消订单',
icon: <CloseCircleOutlined />,
danger: true,
onClick: () => handleUpdateStatus(record.id, 'cancelled'),
})
}
return items
}
const columns = [
{
title: '订单号',
dataIndex: 'id',
key: 'id',
width: 200,
render: (text: string) => (
<Text copyable style={{ fontFamily: 'monospace' }}>
{text}
</Text>
),
},
{
title: '用户ID',
dataIndex: 'user_id',
key: 'user_id',
ellipsis: true,
render: (text: string) => <Tag>{text?.substring(0, 8)}...</Tag>,
},
{
title: '金额',
dataIndex: 'amount',
key: 'amount',
render: (amount: number) => (
<Text strong style={{ color: '#f5222d' }}>
¥{amount?.toFixed(2)}
</Text>
),
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
render: (status: string) => getStatusTag(status),
},
{
title: '创建时间',
dataIndex: 'created_at',
key: 'created_at',
render: (text: string) => new Date(text).toLocaleString('zh-CN'),
},
{
title: '操作',
key: 'action',
width: 150,
render: (_: unknown, record: Order) => (
<Space>
<Button
type="text"
icon={<EyeOutlined />}
onClick={() => handleViewDetail(record)}
>
</Button>
<Dropdown
menu={{ items: getActionItems(record) }}
placement="bottomRight"
>
<Button type="text" icon={<MoreOutlined />} />
</Dropdown>
</Space>
),
},
]
return (
<div className="order-management">
<div className="page-header">
<Title level={4}></Title>
</div>
<Card className="search-card" bordered={false}>
<Space wrap>
<Input
placeholder="搜索订单号/用户ID"
value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
onPressEnter={handleSearch}
prefix={<SearchOutlined />}
style={{ width: 250 }}
allowClear
/>
<Select
placeholder="订单状态"
value={statusFilter || undefined}
onChange={setStatusFilter}
style={{ width: 150 }}
allowClear
>
<Option value="pending"></Option>
<Option value="paid"></Option>
<Option value="printing"></Option>
<Option value="completed"></Option>
<Option value="cancelled"></Option>
</Select>
<RangePicker
onChange={(dates) => {
if (dates) {
setDateRange([
dates[0]?.format('YYYY-MM-DD') || '',
dates[1]?.format('YYYY-MM-DD') || '',
])
} else {
setDateRange(null)
}
}}
/>
<Button type="primary" icon={<SearchOutlined />} onClick={handleSearch}>
</Button>
</Space>
</Card>
<Card className="table-card" bordered={false}>
<Table
columns={columns}
dataSource={orders}
rowKey="id"
loading={loading}
pagination={{
current: currentPage,
pageSize: pageSize,
total: total,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total) => `${total} 条记录`,
onChange: (page, size) => {
setCurrentPage(page)
setPageSize(size || 10)
fetchOrders(page, size || 10)
},
}}
/>
</Card>
<Modal
title="订单详情"
open={detailModalVisible}
onCancel={() => setDetailModalVisible(false)}
footer={null}
width={700}
>
{selectedOrder && (
<Descriptions column={2} bordered>
<Descriptions.Item label="订单号" span={2}>
<Text copyable>{selectedOrder.id}</Text>
</Descriptions.Item>
<Descriptions.Item label="用户ID" span={2}>
<Tag>{selectedOrder.user_id}</Tag>
</Descriptions.Item>
<Descriptions.Item label="文件ID" span={2}>
<Tag>{selectedOrder.file_id}</Tag>
</Descriptions.Item>
<Descriptions.Item label="订单金额">
<Text strong style={{ color: '#f5222d' }}>
¥{selectedOrder.amount?.toFixed(2)}
</Text>
</Descriptions.Item>
<Descriptions.Item label="订单状态">
{getStatusTag(selectedOrder.status)}
</Descriptions.Item>
<Descriptions.Item label="创建时间" span={2}>
{new Date(selectedOrder.created_at).toLocaleString('zh-CN')}
</Descriptions.Item>
<Descriptions.Item label="更新时间" span={2}>
{new Date(selectedOrder.updated_at).toLocaleString('zh-CN')}
</Descriptions.Item>
</Descriptions>
)}
</Modal>
</div>
)
}
export default OrderManagement

View File

@@ -0,0 +1,22 @@
.price-management {
padding: 0;
}
.page-header {
margin-bottom: 24px;
}
.page-header h4 {
margin: 0;
}
.table-card {
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.table-toolbar {
margin-bottom: 16px;
display: flex;
justify-content: flex-end;
}

View File

@@ -0,0 +1,277 @@
import { useEffect, useState } from 'react'
import {
Table,
Card,
Button,
Tag,
Space,
Typography,
message,
Modal,
Form,
Input,
InputNumber,
Select,
Popconfirm,
} from 'antd'
import {
PlusOutlined,
EditOutlined,
DeleteOutlined,
DollarOutlined,
} from '@ant-design/icons'
import { getPriceList, createPrice, updatePrice, deletePrice } from '@/api/admin'
import type { PriceItem } from '@/types/api'
import './index.css'
const { Title, Text } = Typography
const { Option } = Select
const PriceManagement = () => {
const [loading, setLoading] = useState(false)
const [prices, setPrices] = useState<PriceItem[]>([])
const [modalVisible, setModalVisible] = useState(false)
const [editingPrice, setEditingPrice] = useState<PriceItem | null>(null)
const [form] = Form.useForm()
const fetchPrices = async () => {
setLoading(true)
try {
const res = await getPriceList()
if (res.data) {
setPrices(res.data.prices)
}
} catch (error) {
console.error('Failed to fetch prices:', error)
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchPrices()
}, [])
const handleAdd = () => {
setEditingPrice(null)
form.resetFields()
setModalVisible(true)
}
const handleEdit = (record: PriceItem) => {
setEditingPrice(record)
form.setFieldsValue({
paper_size: record.paper_size,
color_type: record.color_type,
single_sided_price: record.single_sided_price,
double_sided_price: record.double_sided_price,
})
setModalVisible(true)
}
const handleDelete = async (id: string) => {
try {
await deletePrice(id)
message.success('删除成功')
fetchPrices()
} catch (error) {
message.error('删除失败')
}
}
const handleSubmit = async (values: any) => {
try {
if (editingPrice) {
await updatePrice({
id: editingPrice.id,
single_sided_price: values.single_sided_price,
double_sided_price: values.double_sided_price,
})
message.success('更新成功')
} else {
await createPrice(values)
message.success('创建成功')
}
setModalVisible(false)
fetchPrices()
} catch (error) {
message.error(editingPrice ? '更新失败' : '创建失败')
}
}
const getColorTypeTag = (type: string) => {
return type === 'color' ? (
<Tag color="blue"></Tag>
) : (
<Tag color="default"></Tag>
)
}
const columns = [
{
title: '纸张尺寸',
dataIndex: 'paper_size',
key: 'paper_size',
render: (text: string) => <Tag color="purple">{text}</Tag>,
},
{
title: '颜色类型',
dataIndex: 'color_type',
key: 'color_type',
render: (type: string) => getColorTypeTag(type),
},
{
title: '单面价格',
dataIndex: 'single_sided_price',
key: 'single_sided_price',
render: (price: number) => (
<Text strong style={{ color: '#f5222d' }}>
¥{price?.toFixed(2)}/
</Text>
),
},
{
title: '双面价格',
dataIndex: 'double_sided_price',
key: 'double_sided_price',
render: (price: number) => (
<Text strong style={{ color: '#f5222d' }}>
¥{price?.toFixed(2)}/
</Text>
),
},
{
title: '创建时间',
dataIndex: 'created_at',
key: 'created_at',
render: (text: string) => new Date(text).toLocaleString('zh-CN'),
},
{
title: '操作',
key: 'action',
width: 180,
render: (_: unknown, record: PriceItem) => (
<Space>
<Button
type="text"
icon={<EditOutlined />}
onClick={() => handleEdit(record)}
>
</Button>
<Popconfirm
title="确认删除"
description="确定要删除这个价目吗?"
onConfirm={() => handleDelete(record.id)}
okText="确定"
cancelText="取消"
okButtonProps={{ danger: true }}
>
<Button type="text" danger icon={<DeleteOutlined />}>
</Button>
</Popconfirm>
</Space>
),
},
]
return (
<div className="price-management">
<div className="page-header">
<Title level={4}></Title>
</div>
<Card className="table-card" bordered={false}>
<div className="table-toolbar">
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>
</Button>
</div>
<Table
columns={columns}
dataSource={prices}
rowKey="id"
loading={loading}
pagination={false}
/>
</Card>
<Modal
title={editingPrice ? '编辑价目' : '添加价目'}
open={modalVisible}
onCancel={() => setModalVisible(false)}
onOk={() => form.submit()}
okText={editingPrice ? '更新' : '创建'}
width={500}
>
<Form
form={form}
layout="vertical"
onFinish={handleSubmit}
initialValues={{
color_type: 'black_white',
}}
>
<Form.Item
name="paper_size"
label="纸张尺寸"
rules={[{ required: true, message: '请输入纸张尺寸' }]}
>
<Select
placeholder="选择纸张尺寸"
disabled={!!editingPrice}
>
<Option value="A4">A4</Option>
<Option value="A3">A3</Option>
<Option value="A5">A5</Option>
<Option value="B5">B5</Option>
<Option value="Letter">Letter</Option>
</Select>
</Form.Item>
<Form.Item
name="color_type"
label="颜色类型"
rules={[{ required: true, message: '请选择颜色类型' }]}
>
<Select placeholder="选择颜色类型" disabled={!!editingPrice}>
<Option value="black_white"></Option>
<Option value="color"></Option>
</Select>
</Form.Item>
<Form.Item
name="single_sided_price"
label="单面价格(元/页)"
rules={[{ required: true, message: '请输入单面价格' }]}
>
<InputNumber
min={0}
precision={2}
prefix={<DollarOutlined />}
style={{ width: '100%' }}
placeholder="0.00"
/>
</Form.Item>
<Form.Item
name="double_sided_price"
label="双面价格(元/页)"
rules={[{ required: true, message: '请输入双面价格' }]}
>
<InputNumber
min={0}
precision={2}
prefix={<DollarOutlined />}
style={{ width: '100%' }}
placeholder="0.00"
/>
</Form.Item>
</Form>
</Modal>
</div>
)
}
export default PriceManagement

View File

@@ -0,0 +1,37 @@
.print-settings {
padding: 0;
}
.page-header {
margin-bottom: 24px;
}
.page-header h4 {
margin: 0;
}
.settings-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
gap: 24px;
}
.settings-card {
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.settings-card .ant-card-head {
border-bottom: 1px solid #f0f0f0;
}
.form-actions {
margin-top: 24px;
margin-bottom: 0;
}
@media (max-width: 768px) {
.settings-grid {
grid-template-columns: 1fr;
}
}

View File

@@ -0,0 +1,288 @@
import { useEffect, useState } from 'react'
import {
Card,
Form,
Input,
Select,
Switch,
Button,
Typography,
message,
Tag,
Space,
Divider,
List,
Badge,
} from 'antd'
import {
PrinterOutlined,
SaveOutlined,
ReloadOutlined,
CheckCircleOutlined,
CloseCircleOutlined,
SettingOutlined,
} from '@ant-design/icons'
import { getPrintSettings, updatePrintSettings } from '@/api/admin'
import type { PrintSettings as PrintSettingsType } from '@/types/api'
import './index.css'
const { Title, Text } = Typography
const { Option } = Select
const PrintSettings = () => {
const [loading, setLoading] = useState(false)
const [saving, setSaving] = useState(false)
const [settings, setSettings] = useState<PrintSettingsType[]>([])
const [form] = Form.useForm()
const fetchSettings = async () => {
setLoading(true)
try {
const res = await getPrintSettings()
if (res.data) {
setSettings(res.data)
}
} catch (error) {
console.error('Failed to fetch settings:', error)
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchSettings()
}, [])
const handleSave = async (values: any) => {
setSaving(true)
try {
await updatePrintSettings(values)
message.success('设置保存成功')
fetchSettings()
} catch (error) {
message.error('保存失败')
} finally {
setSaving(false)
}
}
const getStatusBadge = (status: string) => {
switch (status) {
case 'online':
return <Badge status="success" text="在线" />
case 'offline':
return <Badge status="default" text="离线" />
case 'error':
return <Badge status="error" text="故障" />
default:
return <Badge status="default" text={status} />
}
}
const printerList = [
{
id: '1',
name: '主打印机 - HP LaserJet Pro',
status: 'online',
paper_sizes: ['A4', 'A3', 'Letter'],
color_types: ['black_white', 'color'],
},
{
id: '2',
name: '备用打印机 - Canon PIXMA',
status: 'offline',
paper_sizes: ['A4', 'A5'],
color_types: ['color'],
},
]
return (
<div className="print-settings">
<div className="page-header">
<Title level={4}></Title>
</div>
<div className="settings-grid">
<Card
title={
<Space>
<PrinterOutlined />
<span></span>
</Space>
}
className="settings-card"
bordered={false}
loading={loading}
>
<List
dataSource={printerList}
renderItem={(printer) => (
<List.Item
actions={[
<Button type="text" icon={<SettingOutlined />}>
</Button>,
]}
>
<List.Item.Meta
title={
<Space>
<Text strong>{printer.name}</Text>
{getStatusBadge(printer.status)}
</Space>
}
description={
<Space wrap>
<Text type="secondary">:</Text>
{printer.paper_sizes.map((size) => (
<Tag key={size} size="small">
{size}
</Tag>
))}
<Text type="secondary">:</Text>
{printer.color_types.map((type) => (
<Tag key={type} size="small" color={type === 'color' ? 'blue' : 'default'}>
{type === 'color' ? '彩色' : '黑白'}
</Tag>
))}
</Space>
}
/>
</List.Item>
)}
/>
</Card>
<Card
title={
<Space>
<SettingOutlined />
<span></span>
</Space>
}
className="settings-card"
bordered={false}
>
<Form
form={form}
layout="vertical"
onFinish={handleSave}
initialValues={{
max_file_size: 50,
allowed_formats: ['pdf', 'doc', 'docx', 'jpg', 'png'],
auto_print: false,
notify_user: true,
}}
>
<Form.Item
label="最大文件大小MB"
name="max_file_size"
rules={[{ required: true, message: '请输入最大文件大小' }]}
>
<InputNumber min={1} max={100} style={{ width: '100%' }} />
</Form.Item>
<Form.Item
label="允许的文件格式"
name="allowed_formats"
rules={[{ required: true, message: '请选择允许的文件格式' }]}
>
<Select mode="multiple" placeholder="选择允许的文件格式">
<Option value="pdf">PDF</Option>
<Option value="doc">DOC</Option>
<Option value="docx">DOCX</Option>
<Option value="xls">XLS</Option>
<Option value="xlsx">XLSX</Option>
<Option value="jpg">JPG</Option>
<Option value="png">PNG</Option>
</Select>
</Form.Item>
<Divider />
<Form.Item
label="自动打印"
name="auto_print"
valuePropName="checked"
>
<Switch
checkedChildren={<CheckCircleOutlined />}
unCheckedChildren={<CloseCircleOutlined />}
/>
</Form.Item>
<Form.Item
label="用户通知"
name="notify_user"
valuePropName="checked"
>
<Switch
checkedChildren={<CheckCircleOutlined />}
unCheckedChildren={<CloseCircleOutlined />}
/>
</Form.Item>
<Form.Item className="form-actions">
<Space>
<Button
type="primary"
htmlType="submit"
icon={<SaveOutlined />}
loading={saving}
>
</Button>
<Button icon={<ReloadOutlined />} onClick={fetchSettings}>
</Button>
</Space>
</Form.Item>
</Form>
</Card>
<Card
title={
<Space>
<CheckCircleOutlined />
<span></span>
</Space>
}
className="settings-card"
bordered={false}
>
<Form layout="vertical">
<Form.Item label="默认纸张尺寸" name="default_paper_size">
<Select defaultValue="A4">
<Option value="A4">A4</Option>
<Option value="A3">A3</Option>
<Option value="A5">A5</Option>
<Option value="B5">B5</Option>
<Option value="Letter">Letter</Option>
</Select>
</Form.Item>
<Form.Item label="默认颜色" name="default_color">
<Select defaultValue="black_white">
<Option value="black_white"></Option>
<Option value="color"></Option>
</Select>
</Form.Item>
<Form.Item label="默认单双面" name="default_duplex">
<Select defaultValue="single">
<Option value="single"></Option>
<Option value="double"></Option>
</Select>
</Form.Item>
<Form.Item label="默认份数" name="default_copies">
<InputNumber min={1} max={100} defaultValue={1} style={{ width: '100%' }} />
</Form.Item>
</Form>
</Card>
</div>
</div>
)
}
export default PrintSettings

View File

@@ -0,0 +1,68 @@
.statistics-container {
padding: 0;
}
.statistics-loading {
display: flex;
justify-content: center;
align-items: center;
min-height: 400px;
}
.page-header {
margin-bottom: 24px;
}
.page-header h4 {
margin: 0;
}
.filter-card {
margin-bottom: 16px;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.stat-row {
margin-bottom: 16px;
}
.stat-card {
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
transition: all 0.3s ease;
}
.stat-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
}
.chart-row {
margin-bottom: 16px;
}
.chart-card {
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.summary-row {
margin-top: 16px;
}
.summary-card {
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.chart-placeholder {
height: 250px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: #fafafa;
border-radius: 8px;
gap: 16px;
}

View File

@@ -0,0 +1,221 @@
import { useEffect, useState } from 'react'
import {
Card,
Row,
Col,
Statistic,
DatePicker,
Button,
Typography,
Table,
Tag,
Spin,
} from 'antd'
import {
BarChartOutlined,
PieChartOutlined,
RiseOutlined,
FallOutlined,
CalendarOutlined,
ReloadOutlined,
} from '@ant-design/icons'
import { getStatistics, getDailyStatistics } from '@/api/admin'
import type { StatisticsData } from '@/types/api'
import './index.css'
const { Title, Text } = Typography
const { RangePicker } = DatePicker
const Statistics = () => {
const [loading, setLoading] = useState(true)
const [stats, setStats] = useState<StatisticsData | null>(null)
const [dateRange, setDateRange] = useState<[string, string] | null>(null)
const fetchStatistics = async () => {
setLoading(true)
try {
const params: any = {}
if (dateRange) {
params.start_date = dateRange[0]
params.end_date = dateRange[1]
}
const res = await getStatistics(params)
if (res.data) {
setStats(res.data)
}
} catch (error) {
console.error('Failed to fetch statistics:', error)
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchStatistics()
}, [])
const weeklyColumns = [
{
title: '日期',
dataIndex: 'date',
key: 'date',
render: (text: string) => (
<Space>
<CalendarOutlined />
{text}
</Space>
),
},
{
title: '订单数',
dataIndex: 'orders',
key: 'orders',
render: (value: number) => <Tag color="blue">{value} </Tag>,
},
{
title: '收入',
dataIndex: 'revenue',
key: 'revenue',
render: (value: number) => (
<Text strong style={{ color: '#52c41a' }}>
¥{value?.toFixed(2)}
</Text>
),
},
{
title: '趋势',
key: 'trend',
render: (_: unknown, record: any, index: number) => {
if (index === 0) return '-'
const prev = stats?.weekly_data[index - 1]
const growth = prev ? ((record.revenue - prev.revenue) / prev.revenue) * 100 : 0
return growth >= 0 ? (
<Tag color="success" icon={<RiseOutlined />}>
+{growth.toFixed(1)}%
</Tag>
) : (
<Tag color="error" icon={<FallOutlined />}>
{growth.toFixed(1)}%
</Tag>
)
},
},
]
if (loading) {
return (
<div className="statistics-loading">
<Spin size="large" />
</div>
)
}
return (
<div className="statistics-container">
<div className="page-header">
<Title level={4}></Title>
</div>
<Card className="filter-card" bordered={false}>
<Space>
<RangePicker
onChange={(dates) => {
if (dates) {
setDateRange([
dates[0]?.format('YYYY-MM-DD') || '',
dates[1]?.format('YYYY-MM-DD') || '',
])
} else {
setDateRange(null)
}
}}
/>
<Button type="primary" icon={<BarChartOutlined />} onClick={fetchStatistics}>
</Button>
<Button icon={<ReloadOutlined />} onClick={fetchStatistics}>
</Button>
</Space>
</Card>
<Row gutter={[16, 16]} className="stat-row">
<Col xs={24} sm={12} lg={6}>
<Card className="stat-card" bordered={false}>
<Statistic
title="总用户数"
value={stats?.total_users || 0}
prefix={<BarChartOutlined />}
valueStyle={{ color: '#1890ff' }}
/>
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card className="stat-card" bordered={false}>
<Statistic
title="总订单数"
value={stats?.total_orders || 0}
prefix={<PieChartOutlined />}
valueStyle={{ color: '#52c41a' }}
/>
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card className="stat-card" bordered={false}>
<Statistic
title="总文件数"
value={stats?.total_files || 0}
prefix={<BarChartOutlined />}
valueStyle={{ color: '#722ed1' }}
/>
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card className="stat-card" bordered={false}>
<Statistic
title="总收入"
value={stats?.total_revenue || 0}
precision={2}
prefix="¥"
valueStyle={{ color: '#fa8c16' }}
/>
</Card>
</Col>
</Row>
<Row gutter={[16, 16]} className="chart-row">
<Col span={24}>
<Card title="每日数据统计" bordered={false} className="chart-card">
<Table
columns={weeklyColumns}
dataSource={stats?.weekly_data || []}
rowKey="date"
pagination={false}
/>
</Card>
</Col>
</Row>
<Row gutter={[16, 16]} className="summary-row">
<Col xs={24} lg={12}>
<Card title="订单状态分布" bordered={false} className="summary-card">
<div className="chart-placeholder">
<PieChartOutlined style={{ fontSize: 48, color: '#d9d9d9' }} />
<Text type="secondary">...</Text>
</div>
</Card>
</Col>
<Col xs={24} lg={12}>
<Card title="收入趋势" bordered={false} className="summary-card">
<div className="chart-placeholder">
<BarChartOutlined style={{ fontSize: 48, color: '#d9d9d9' }} />
<Text type="secondary">...</Text>
</div>
</Card>
</Col>
</Row>
</div>
)
}
export default Statistics

View File

@@ -0,0 +1,30 @@
.user-management {
padding: 0;
}
.page-header {
margin-bottom: 24px;
}
.page-header h4 {
margin: 0;
}
.search-card {
margin-bottom: 16px;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.table-card {
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.table-card .ant-table {
font-size: 14px;
}
.table-card .ant-avatar {
background: linear-gradient(135deg, #1890ff 0%, #36cfc9 100%);
}

View File

@@ -0,0 +1,198 @@
import { useEffect, useState } from 'react'
import {
Table,
Card,
Input,
Button,
Avatar,
Tag,
Space,
Typography,
message,
Modal,
Descriptions,
} from 'antd'
import { SearchOutlined, EyeOutlined, UserOutlined } from '@ant-design/icons'
import { getUserList, getUserDetail } from '@/api/admin'
import type { User } from '@/types/api'
import './index.css'
const { Title } = Typography
const UserManagement = () => {
const [loading, setLoading] = useState(false)
const [users, setUsers] = useState<User[]>([])
const [total, setTotal] = useState(0)
const [currentPage, setCurrentPage] = useState(1)
const [pageSize, setPageSize] = useState(10)
const [searchKeyword, setSearchKeyword] = useState('')
const [selectedUser, setSelectedUser] = useState<User | null>(null)
const [detailModalVisible, setDetailModalVisible] = useState(false)
const fetchUsers = async (page = currentPage, size = pageSize, keyword = searchKeyword) => {
setLoading(true)
try {
const res = await getUserList({
page,
page_size: size,
keyword,
})
if (res.data) {
setUsers(res.data.users)
setTotal(res.data.total)
}
} catch (error) {
console.error('Failed to fetch users:', error)
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchUsers()
}, [])
const handleSearch = () => {
setCurrentPage(1)
fetchUsers(1, pageSize, searchKeyword)
}
const handleViewDetail = async (user: User) => {
try {
const res = await getUserDetail(user.id)
if (res.data) {
setSelectedUser(res.data)
setDetailModalVisible(true)
}
} catch (error) {
message.error('获取用户详情失败')
}
}
const columns = [
{
title: '用户',
dataIndex: 'nickname',
key: 'nickname',
render: (text: string, record: User) => (
<Space>
<Avatar src={record.avatar} icon={<UserOutlined />} />
<span>{text || '未设置昵称'}</span>
</Space>
),
},
{
title: 'OpenID',
dataIndex: 'openid',
key: 'openid',
ellipsis: true,
render: (text: string) => (
<Tag color="blue" style={{ fontFamily: 'monospace' }}>
{text?.substring(0, 16)}...
</Tag>
),
},
{
title: '手机号',
dataIndex: 'phone',
key: 'phone',
render: (text: string) => text || <Tag color="default"></Tag>,
},
{
title: '注册时间',
dataIndex: 'created_at',
key: 'created_at',
render: (text: string) => new Date(text).toLocaleString('zh-CN'),
},
{
title: '操作',
key: 'action',
width: 120,
render: (_: unknown, record: User) => (
<Button
type="text"
icon={<EyeOutlined />}
onClick={() => handleViewDetail(record)}
>
</Button>
),
},
]
return (
<div className="user-management">
<div className="page-header">
<Title level={4}></Title>
</div>
<Card className="search-card" bordered={false}>
<Space>
<Input
placeholder="搜索用户昵称/OpenID"
value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
onPressEnter={handleSearch}
prefix={<SearchOutlined />}
style={{ width: 300 }}
allowClear
/>
<Button type="primary" icon={<SearchOutlined />} onClick={handleSearch}>
</Button>
</Space>
</Card>
<Card className="table-card" bordered={false}>
<Table
columns={columns}
dataSource={users}
rowKey="id"
loading={loading}
pagination={{
current: currentPage,
pageSize: pageSize,
total: total,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total) => `${total} 条记录`,
onChange: (page, size) => {
setCurrentPage(page)
setPageSize(size || 10)
fetchUsers(page, size || 10)
},
}}
/>
</Card>
<Modal
title="用户详情"
open={detailModalVisible}
onCancel={() => setDetailModalVisible(false)}
footer={null}
width={600}
>
{selectedUser && (
<Descriptions column={1} bordered>
<Descriptions.Item label="用户头像">
<Avatar src={selectedUser.avatar} size={64} icon={<UserOutlined />} />
</Descriptions.Item>
<Descriptions.Item label="昵称">{selectedUser.nickname || '未设置'}</Descriptions.Item>
<Descriptions.Item label="OpenID">
<Tag color="blue">{selectedUser.openid}</Tag>
</Descriptions.Item>
<Descriptions.Item label="手机号">{selectedUser.phone || '未绑定'}</Descriptions.Item>
<Descriptions.Item label="注册时间">
{new Date(selectedUser.created_at).toLocaleString('zh-CN')}
</Descriptions.Item>
<Descriptions.Item label="更新时间">
{new Date(selectedUser.updated_at).toLocaleString('zh-CN')}
</Descriptions.Item>
</Descriptions>
)}
</Modal>
</div>
)
}
export default UserManagement

114
src/types/api.ts Normal file
View File

@@ -0,0 +1,114 @@
// Admin Service API Types based on OpenAPI specification
export interface AdminLoginRequest {
username: string
password: string
}
export interface AdminLoginResponse {
token: string
expire_time: string
}
export interface User {
id: string
openid: string
nickname: string
avatar: string
phone: string
created_at: string
updated_at: string
}
export interface UserListResponse {
users: User[]
total: number
}
export interface Order {
id: string
user_id: string
file_id: string
status: 'pending' | 'paid' | 'printing' | 'completed' | 'cancelled'
amount: number
created_at: string
updated_at: string
}
export interface OrderListResponse {
orders: Order[]
total: number
}
export interface File {
id: string
user_id: string
name: string
url: string
size: number
page_count: number
created_at: string
}
export interface FileListResponse {
files: File[]
total: number
}
export interface PriceItem {
id: string
paper_size: string
color_type: 'black_white' | 'color'
single_sided_price: number
double_sided_price: number
created_at: string
updated_at: string
}
export interface PriceListResponse {
prices: PriceItem[]
total: number
}
export interface CreatePriceRequest {
paper_size: string
color_type: 'black_white' | 'color'
single_sided_price: number
double_sided_price: number
}
export interface UpdatePriceRequest {
id: string
single_sided_price: number
double_sided_price: number
}
export interface PrintSettings {
id: string
printer_name: string
status: 'online' | 'offline' | 'error'
paper_sizes: string[]
color_types: string[]
created_at: string
updated_at: string
}
export interface StatisticsData {
total_users: number
total_orders: number
total_files: number
total_revenue: number
today_orders: number
today_revenue: number
weekly_data: {
date: string
orders: number
revenue: number
}[]
}
export interface ApiResponse<T> {
code: number
message: string
data: T
}