wip: oci image management

This commit is contained in:
loveuer
2025-11-09 15:19:11 +08:00
commit 8de8234372
58 changed files with 6142 additions and 0 deletions

18
frontend/.eslintrc.cjs Normal file
View File

@@ -0,0 +1,18 @@
module.exports = {
root: true,
env: { browser: true, es2020: true },
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended',
],
ignorePatterns: ['dist', '.eslintrc.cjs'],
parser: '@typescript-eslint/parser',
plugins: ['react-refresh'],
rules: {
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
}

24
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

77
frontend/README.md Normal file
View File

@@ -0,0 +1,77 @@
# Cluster Frontend
基于 React + TypeScript + Zustand + MUI 的前端项目。
## 技术栈
- **React 18** - UI 框架
- **TypeScript** - 类型系统
- **Vite** - 构建工具
- **Zustand** - 状态管理
- **Material-UI (MUI)** - UI 组件库
## 开发
### 安装依赖
```bash
npm install
# 或
yarn install
# 或
pnpm install
```
### 启动开发服务器
```bash
npm run dev
# 或
yarn dev
# 或
pnpm dev
```
开发服务器将在 `http://localhost:3000` 启动。
### 构建生产版本
```bash
npm run build
# 或
yarn build
# 或
pnpm build
```
### 预览生产构建
```bash
npm run preview
# 或
yarn preview
# 或
pnpm preview
```
## 项目结构
```
frontend/
├── src/
│ ├── stores/ # Zustand 状态管理
│ ├── components/ # React 组件
│ ├── theme.ts # MUI 主题配置
│ ├── App.tsx # 主应用组件
│ └── main.tsx # 应用入口
├── public/ # 静态资源
├── index.html # HTML 模板
├── vite.config.ts # Vite 配置
└── tsconfig.json # TypeScript 配置
```
## API 代理
开发环境已配置 API 代理,所有 `/api/*` 请求会被代理到 `http://localhost:8080`Go 后端服务)。
配置位置:`vite.config.ts`

15
frontend/index.html Normal file
View File

@@ -0,0 +1,15 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- 霞鹜文楷字体 -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/lxgw-wenkai-webfont@1.1.0/style.css" />
<title>Cluster</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

34
frontend/package.json Normal file
View File

@@ -0,0 +1,34 @@
{
"name": "cluster-frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
},
"dependencies": {
"@emotion/react": "^11.13.0",
"@emotion/styled": "^11.13.0",
"@mui/icons-material": "^5.16.7",
"@mui/material": "^5.16.7",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.0",
"zustand": "^4.5.2"
},
"devDependencies": {
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@typescript-eslint/eslint-plugin": "^7.13.1",
"@typescript-eslint/parser": "^7.13.1",
"@vitejs/plugin-react": "^4.3.1",
"eslint": "^8.57.0",
"eslint-plugin-react-hooks": "^4.6.2",
"eslint-plugin-react-refresh": "^0.4.7",
"typescript": "^5.9.3",
"vite": "^5.4.21"
}
}

2691
frontend/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

55
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,55 @@
import { Container, Typography, Box, AppBar, Toolbar, Button, Stack } from '@mui/material'
import { Routes, Route, Link } from 'react-router-dom'
import { useAppStore } from './stores/appStore'
import RegistryImageList from './pages/RegistryImageList'
function App() {
const { count, increment, decrement, reset } = useAppStore()
return (
<Box sx={{ flexGrow: 1, minHeight: '100vh' }}>
<AppBar position="static">
<Toolbar>
<Typography variant="h6" component="div" sx={{ flexGrow: 1 }}>
Cluster
</Typography>
<Button color="inherit" component={Link} to="/"></Button>
<Button color="inherit" component={Link} to="/registry/image"></Button>
</Toolbar>
</AppBar>
<Container maxWidth="lg" sx={{ mt: 4, mb: 4 }}>
<Routes>
<Route path="/registry/image" element={<RegistryImageList />} />
<Route path="/" element={
<Box>
<Typography variant="h4" component="h1" gutterBottom>
使 Cluster
</Typography>
<Box sx={{ mt: 4 }}>
<Typography variant="h6" gutterBottom>
Zustand
</Typography>
<Stack direction="row" spacing={2} alignItems="center" justifyContent="center" sx={{ mt: 2 }}>
<Button variant="contained" onClick={decrement}>
-
</Button>
<Typography variant="h5" sx={{ minWidth: 60 }}>
{count}
</Typography>
<Button variant="contained" onClick={increment}>
+
</Button>
<Button variant="outlined" onClick={reset} sx={{ ml: 2 }}>
</Button>
</Stack>
</Box>
</Box>
} />
</Routes>
</Container>
</Box>
)
}
export default App

View File

15
frontend/src/index.css Normal file
View File

@@ -0,0 +1,15 @@
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: 'LXGW WenKai', '霞鹜文楷', Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
#root {
width: 100%;
min-height: 100vh;
}

18
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,18 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { CssBaseline, ThemeProvider } from '@mui/material'
import { BrowserRouter } from 'react-router-dom'
import App from './App.tsx'
import { theme } from './theme'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<ThemeProvider theme={theme}>
<CssBaseline />
<BrowserRouter>
<App />
</BrowserRouter>
</ThemeProvider>
</React.StrictMode>,
)

View File

@@ -0,0 +1,75 @@
import { useEffect, useState } from 'react'
import { Box, Typography, Paper, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, CircularProgress, Alert } from '@mui/material'
interface RegistryImage {
id: number
name: string
created_at?: string
updated_at?: string
}
export default function RegistryImageList() {
const [images, setImages] = useState<RegistryImage[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let abort = false
async function fetchImages() {
setLoading(true)
setError(null)
try {
const res = await fetch('/api/v1/registry/image/list')
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data = await res.json()
const list: RegistryImage[] = data.images || []
if (!abort) setImages(list)
} catch (e: any) {
if (!abort) setError(e.message)
} finally {
if (!abort) setLoading(false)
}
}
fetchImages()
return () => { abort = true }
}, [])
return (
<Box>
<Typography variant="h5" gutterBottom></Typography>
{loading && <CircularProgress />}
{error && <Alert severity="error">: {error}</Alert>}
{!loading && !error && (
<Paper>
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>ID</TableCell>
<TableCell></TableCell>
<TableCell></TableCell>
<TableCell></TableCell>
</TableRow>
</TableHead>
<TableBody>
{images.map(img => (
<TableRow key={img.id} hover>
<TableCell>{img.id}</TableCell>
<TableCell>{img.name}</TableCell>
<TableCell>{img.created_at?.replace('T', ' ').replace('Z', '')}</TableCell>
<TableCell>{img.updated_at?.replace('T', ' ').replace('Z', '')}</TableCell>
</TableRow>
))}
{images.length === 0 && (
<TableRow>
<TableCell colSpan={4} align="center"></TableCell>
</TableRow>
)}
</TableBody>
</Table>
</TableContainer>
</Paper>
)}
</Box>
)
}

View File

@@ -0,0 +1,15 @@
import { create } from 'zustand'
interface AppState {
count: number
increment: () => void
decrement: () => void
reset: () => void
}
export const useAppStore = create<AppState>((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
decrement: () => set((state) => ({ count: state.count - 1 })),
reset: () => set({ count: 0 }),
}))

27
frontend/src/theme.ts Normal file
View File

@@ -0,0 +1,27 @@
import { createTheme } from '@mui/material/styles'
export const theme = createTheme({
palette: {
mode: 'light',
primary: {
main: '#1976d2',
},
secondary: {
main: '#dc004e',
},
},
typography: {
fontFamily: [
'"LXGW WenKai"',
'"霞鹜文楷"',
'Inter',
'-apple-system',
'BlinkMacSystemFont',
'"Segoe UI"',
'Roboto',
'"Helvetica Neue"',
'Arial',
'sans-serif',
].join(','),
},
})

1
frontend/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

25
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}

18
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,18 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
server: {
host: '0.0.0.0',
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
// Removed rewrite so /api prefix is preserved for backend route /api/v1/...
},
},
},
})