feat: 官网 + 管理端 + Gin/GORM 后端首个完整版本 #1
@@ -0,0 +1,16 @@
|
||||
# 依赖与构建产物(镜像内重新构建)
|
||||
**/node_modules
|
||||
web/dist
|
||||
admin/dist
|
||||
bin/
|
||||
server/internal/webfs/dist/*
|
||||
!server/internal/webfs/dist/index.html
|
||||
server/internal/webfs/admin_dist/*
|
||||
!server/internal/webfs/admin_dist/index.html
|
||||
|
||||
# 本地/敏感
|
||||
.env
|
||||
*.db
|
||||
.git
|
||||
design-assets/
|
||||
.DS_Store
|
||||
@@ -0,0 +1,11 @@
|
||||
# 复制为 .env 并按需填写
|
||||
# MySQL:user:pass@tcp(host:port)/dbname?charset=utf8mb4&parseTime=True&loc=Local
|
||||
# 留空或填文件路径则使用本地 SQLite
|
||||
SUNDYNIX_DB=
|
||||
SUNDYNIX_ADDR=:8090
|
||||
SUNDYNIX_ADMIN_USER=admin
|
||||
SUNDYNIX_ADMIN_PASS=
|
||||
SUNDYNIX_JWT_SECRET=
|
||||
SUNDYNIX_NODE_ID=1
|
||||
# RSS 等对外链接使用的站点地址
|
||||
SUNDYNIX_SITE_URL=https://sundynix.com
|
||||
@@ -0,0 +1,25 @@
|
||||
# 部署机 192.168.100.132:/home/workspace/sundynix-site/.env
|
||||
# 一次性手动放置,CI 不覆盖它(敏感信息不进流水线)
|
||||
# compose 的 env_file 读取本文件
|
||||
|
||||
# 内网 MySQL(127 那台),库名 sundynix_site
|
||||
SUNDYNIX_DB=root:sundynix@tcp(192.168.100.127:3307)/sundynix_site?charset=utf8mb4&parseTime=True&loc=Local
|
||||
|
||||
# 监听地址(容器内固定 8090,勿改,与 compose ports 对应)
|
||||
SUNDYNIX_ADDR=:8090
|
||||
|
||||
# 管理端账号(务必改掉默认密码)
|
||||
SUNDYNIX_ADMIN_USER=sundynix
|
||||
SUNDYNIX_ADMIN_PASS=请改成强密码
|
||||
|
||||
# JWT 密钥(openssl rand -hex 32 生成一个填进来)
|
||||
SUNDYNIX_JWT_SECRET=请填32字节以上随机串
|
||||
|
||||
# 雪花节点号(多实例时区分)
|
||||
SUNDYNIX_NODE_ID=1
|
||||
|
||||
# 对外站点地址(RSS / OG 链接用)
|
||||
SUNDYNIX_SITE_URL=https://site.sundynix.cn
|
||||
|
||||
# 版本回退(gitea 无 release 时官网显示的版本)
|
||||
SUNDYNIX_FALLBACK_VERSION=v0.1.2
|
||||
@@ -0,0 +1,68 @@
|
||||
name: build-and-deploy
|
||||
|
||||
on:
|
||||
# 仅 main 有提交(含 PR 合并入 main)时触发
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
IMAGE: sundynix-site:latest
|
||||
REMOTE_DIR: /home/workspace/sundynix-site
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: 检出代码
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: 构建镜像
|
||||
run: docker build -t "$IMAGE" .
|
||||
|
||||
- name: 导出镜像为压缩包
|
||||
run: docker save "$IMAGE" | gzip > image.tar.gz
|
||||
|
||||
- name: 安装 ssh 工具
|
||||
run: |
|
||||
if command -v apt-get >/dev/null; then
|
||||
apt-get update && apt-get install -y sshpass openssh-client
|
||||
elif command -v apk >/dev/null; then
|
||||
apk add --no-cache sshpass openssh-client
|
||||
fi
|
||||
|
||||
- name: 传输镜像与 compose 到部署机
|
||||
env:
|
||||
SSHPASS: ${{ secrets.DEPLOY_PASSWORD }}
|
||||
run: |
|
||||
H="${{ secrets.DEPLOY_HOST }}"
|
||||
U="${{ secrets.DEPLOY_USER }}"
|
||||
sshpass -e ssh -o StrictHostKeyChecking=no "$U@$H" "mkdir -p $REMOTE_DIR"
|
||||
sshpass -e scp -o StrictHostKeyChecking=no \
|
||||
image.tar.gz docker-compose.yml "$U@$H:$REMOTE_DIR/"
|
||||
|
||||
- name: 部署机加载镜像并重启
|
||||
env:
|
||||
SSHPASS: ${{ secrets.DEPLOY_PASSWORD }}
|
||||
run: |
|
||||
H="${{ secrets.DEPLOY_HOST }}"
|
||||
U="${{ secrets.DEPLOY_USER }}"
|
||||
sshpass -e ssh -o StrictHostKeyChecking=no "$U@$H" "\
|
||||
cd $REMOTE_DIR && \
|
||||
gunzip -c image.tar.gz | docker load && \
|
||||
rm -f image.tar.gz && \
|
||||
docker compose up -d && \
|
||||
docker image prune -f"
|
||||
|
||||
- name: 健康检查
|
||||
env:
|
||||
SSHPASS: ${{ secrets.DEPLOY_PASSWORD }}
|
||||
run: |
|
||||
H="${{ secrets.DEPLOY_HOST }}"
|
||||
U="${{ secrets.DEPLOY_USER }}"
|
||||
sshpass -e ssh -o StrictHostKeyChecking=no "$U@$H" "\
|
||||
for i in \$(seq 1 15); do \
|
||||
wget -qO- http://127.0.0.1:8090/api/healthz && exit 0; \
|
||||
sleep 2; \
|
||||
done; \
|
||||
echo '健康检查失败'; docker logs --tail 50 sundynix-site; exit 1"
|
||||
@@ -0,0 +1,21 @@
|
||||
# 构建产物
|
||||
bin/
|
||||
web/dist/
|
||||
admin/dist/
|
||||
|
||||
# embed 的前端产物:只保留占位 index.html,make build 会重新生成
|
||||
server/internal/webfs/dist/*
|
||||
!server/internal/webfs/dist/index.html
|
||||
server/internal/webfs/admin_dist/*
|
||||
!server/internal/webfs/admin_dist/index.html
|
||||
|
||||
# 本地数据库
|
||||
*.db
|
||||
|
||||
# 本地环境配置(含数据库凭据)
|
||||
.env
|
||||
|
||||
.DS_Store
|
||||
|
||||
# 品牌原始素材(大文件,不进版本库)
|
||||
design-assets/
|
||||
@@ -0,0 +1,36 @@
|
||||
# ── 1. 构建前端(web 用户端 + admin 管理端)──
|
||||
FROM node:20-alpine AS web
|
||||
WORKDIR /app/web
|
||||
COPY web/package*.json ./
|
||||
RUN npm ci
|
||||
COPY web/ ./
|
||||
RUN npm run build
|
||||
|
||||
FROM node:20-alpine AS admin
|
||||
WORKDIR /app/admin
|
||||
COPY admin/package*.json ./
|
||||
RUN npm ci
|
||||
COPY admin/ ./
|
||||
RUN npm run build
|
||||
|
||||
# ── 2. 编译 Go(embed 两份前端产物)──
|
||||
FROM golang:1.26-alpine AS server
|
||||
WORKDIR /app/server
|
||||
COPY server/go.mod server/go.sum ./
|
||||
RUN go mod download
|
||||
COPY server/ ./
|
||||
# 用真实构建产物替换 embed 占位目录
|
||||
COPY --from=web /app/web/dist ./internal/webfs/dist
|
||||
COPY --from=admin /app/admin/dist ./internal/webfs/admin_dist
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" \
|
||||
-o /sundynix-site ./cmd
|
||||
|
||||
# ── 3. 运行镜像 ──
|
||||
FROM alpine:3.20
|
||||
RUN apk add --no-cache ca-certificates tzdata && \
|
||||
adduser -D -u 10001 app
|
||||
ENV TZ=Asia/Shanghai
|
||||
COPY --from=server /sundynix-site /usr/local/bin/sundynix-site
|
||||
USER app
|
||||
EXPOSE 8090
|
||||
ENTRYPOINT ["/usr/local/bin/sundynix-site"]
|
||||
@@ -0,0 +1,29 @@
|
||||
# sundynix-site 构建入口
|
||||
|
||||
.PHONY: dev-web dev-admin dev-server build clean
|
||||
|
||||
# 用户端开发(vite 代理 /api → :8090)
|
||||
dev-web:
|
||||
cd web && npm run dev
|
||||
|
||||
# 管理端开发(vite 代理 /api → :8090,访问 http://localhost:5174/admin/)
|
||||
dev-admin:
|
||||
cd admin && npm run dev
|
||||
|
||||
# 后端开发
|
||||
dev-server:
|
||||
cd server && go run ./cmd
|
||||
|
||||
# 整站构建:web/dist + admin/dist → embed → 单二进制
|
||||
build:
|
||||
cd web && npm run build
|
||||
cd admin && npm run build
|
||||
rm -rf server/internal/webfs/dist server/internal/webfs/admin_dist
|
||||
cp -r web/dist server/internal/webfs/dist
|
||||
cp -r admin/dist server/internal/webfs/admin_dist
|
||||
cd server && go build -o ../bin/sundynix-site ./cmd
|
||||
@echo "✔ bin/sundynix-site"
|
||||
|
||||
clean:
|
||||
rm -rf bin web/dist admin/dist server/sundynix-site.db
|
||||
git checkout -- server/internal/webfs 2>/dev/null || true
|
||||
@@ -0,0 +1,58 @@
|
||||
# sundynix-site
|
||||
|
||||
[sundynix-agentix](https://git.sundynix.cn/Blizzard/sundynix-agentix)(事件驱动的 AI Agent 工作台)的产品官网 + 内容管理端。
|
||||
|
||||
## 结构
|
||||
|
||||
```
|
||||
├── web/ 用户端官网 Vite + React + TS + Tailwind(挂 /)
|
||||
├── admin/ 内容管理端 同栈,JWT 登录(挂 /admin)
|
||||
├── server/ Go 后端 Gin + GORM,MySQL / SQLite
|
||||
│ └── internal/webfs/ 两个前端的构建产物 embed 到这里
|
||||
└── Makefile 构建入口
|
||||
```
|
||||
|
||||
生产形态:`make build` 把 web、admin 的 dist 打进 Go 二进制,单文件部署整站(`bin/sundynix-site`)。
|
||||
|
||||
## 本地开发
|
||||
|
||||
```bash
|
||||
cp .env.example .env # 填 SUNDYNIX_DB(留空用本地 SQLite)
|
||||
|
||||
make dev-server # 后端 :8090
|
||||
make dev-web # 用户端 http://localhost:5173(/api 已代理)
|
||||
make dev-admin # 管理端 http://localhost:5174/admin/
|
||||
```
|
||||
|
||||
管理端默认账号 `admin / admin123`(务必用环境变量覆盖)。
|
||||
|
||||
## 环境变量
|
||||
|
||||
| 变量 | 说明 | 默认 |
|
||||
|------|------|------|
|
||||
| `SUNDYNIX_DB` | 数据库 DSN;含 `@tcp(` 走 MySQL,否则视为 SQLite 文件路径 | `sundynix-site.db` |
|
||||
| `SUNDYNIX_ADDR` | 监听地址 | `:8090` |
|
||||
| `SUNDYNIX_ADMIN_USER` / `SUNDYNIX_ADMIN_PASS` | 管理端账号 | `admin` / `admin123` |
|
||||
| `SUNDYNIX_JWT_SECRET` | JWT 密钥 | 开发默认值(生产必配) |
|
||||
| `SUNDYNIX_NODE_ID` | 雪花算法节点号 | `1` |
|
||||
| `SUNDYNIX_SITE_URL` | RSS 等对外链接的站点地址 | `https://sundynix.com` |
|
||||
|
||||
支持根目录 `.env`(已 gitignore)。
|
||||
|
||||
## 后端规范
|
||||
|
||||
- 表名前缀 `sundynix_`,单数表名(GORM NamingStrategy)
|
||||
- 主键为**字符串雪花 ID**(`internal/pkg/idgen`,模型嵌入 `BaseModel` 自动生成)
|
||||
- 列名 snake_case,禁止驼峰
|
||||
- 统一响应信封 `{code, message, data}`(`internal/pkg/resp`),code=0 成功
|
||||
- 公共分页参数 `page / page_size / keyword`(`internal/pkg/req.PageQuery`)
|
||||
|
||||
## API 速览
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/api/posts` · `/api/posts/:slug` | 公开文章列表 / 详情 |
|
||||
| GET | `/rss.xml` | RSS 2.0 订阅源 |
|
||||
| POST | `/api/admin/login` | 登录,签发 JWT |
|
||||
| GET/POST | `/api/admin/posts` | 文章分页列表(含草稿)/ 新建 |
|
||||
| GET/PUT/DELETE | `/api/admin/posts/:id` | 详情 / 更新(含发布切换)/ 删除 |
|
||||
@@ -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?
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["react", "typescript", "oxc"],
|
||||
"rules": {
|
||||
"react/rules-of-hooks": "error",
|
||||
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the Oxlint configuration
|
||||
|
||||
If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["react", "typescript", "oxc"],
|
||||
"options": {
|
||||
"typeAware": true
|
||||
},
|
||||
"rules": {
|
||||
"react/rules-of-hooks": "error",
|
||||
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories.
|
||||
@@ -0,0 +1,25 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/png" href="/admin/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="robots" content="noindex" />
|
||||
<title>sundynix admin — 内容管理</title>
|
||||
<script>
|
||||
// 避免主题闪烁:React 挂载前先落 dark class
|
||||
;(function () {
|
||||
var t = localStorage.getItem('sundynix-theme')
|
||||
var dark =
|
||||
t === 'dark' ||
|
||||
((t === null || t === 'system') &&
|
||||
window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||
if (dark) document.documentElement.classList.add('dark')
|
||||
})()
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "admin",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "oxlint",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^1.24.0",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-router-dom": "^7.18.1",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tailwindcss": "^4.3.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.13.3",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.3",
|
||||
"oxlint": "^1.71.0",
|
||||
"typescript": "~6.0.2",
|
||||
"vite": "^8.1.1"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 96 KiB |
@@ -0,0 +1,27 @@
|
||||
import { Navigate, Outlet, Route, Routes } from 'react-router-dom'
|
||||
import { getToken } from '@/api/client'
|
||||
import { AdminLayout } from '@/components/admin-layout'
|
||||
import LoginPage from '@/pages/login'
|
||||
import PostsListPage from '@/pages/posts-list'
|
||||
import PostEditPage from '@/pages/post-edit'
|
||||
|
||||
function RequireAuth() {
|
||||
if (!getToken()) return <Navigate to="/login" replace />
|
||||
return <Outlet />
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route element={<RequireAuth />}>
|
||||
<Route element={<AdminLayout />}>
|
||||
<Route index element={<PostsListPage />} />
|
||||
<Route path="posts/new" element={<PostEditPage />} />
|
||||
<Route path="posts/:id" element={<PostEditPage />} />
|
||||
</Route>
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { request, setToken, clearToken } from '@/api/client'
|
||||
|
||||
export async function login(username: string, password: string) {
|
||||
const data = await request<{ token: string; username: string }>(
|
||||
'/api/admin/login',
|
||||
{ method: 'POST', body: JSON.stringify({ username, password }) },
|
||||
)
|
||||
setToken(data.token)
|
||||
return data
|
||||
}
|
||||
|
||||
export function logout() {
|
||||
clearToken()
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/** 管理端 API client:token 注入 + 统一信封解包 + 401 跳登录 */
|
||||
|
||||
const TOKEN_KEY = 'sundynix-admin-token'
|
||||
|
||||
export function getToken(): string | null {
|
||||
return localStorage.getItem(TOKEN_KEY)
|
||||
}
|
||||
|
||||
export function setToken(token: string) {
|
||||
localStorage.setItem(TOKEN_KEY, token)
|
||||
}
|
||||
|
||||
export function clearToken() {
|
||||
localStorage.removeItem(TOKEN_KEY)
|
||||
}
|
||||
|
||||
interface Envelope<T> {
|
||||
code: number
|
||||
message: string
|
||||
data: T
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number
|
||||
code: number
|
||||
|
||||
constructor(status: number, code: number, message: string) {
|
||||
super(message)
|
||||
this.status = status
|
||||
this.code = code
|
||||
}
|
||||
}
|
||||
|
||||
export async function request<T>(
|
||||
path: string,
|
||||
init: RequestInit = {},
|
||||
): Promise<T> {
|
||||
const headers = new Headers(init.headers)
|
||||
headers.set('Content-Type', 'application/json')
|
||||
const token = getToken()
|
||||
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||
|
||||
const res = await fetch(path, { ...init, headers })
|
||||
|
||||
if (res.status === 401) {
|
||||
clearToken()
|
||||
// basename=/admin,登录路由实际是 /admin/login
|
||||
if (!window.location.pathname.endsWith('/login')) {
|
||||
window.location.href = '/admin/login'
|
||||
}
|
||||
throw new ApiError(401, 40100, '登录已过期')
|
||||
}
|
||||
|
||||
let body: Envelope<T> | null = null
|
||||
try {
|
||||
body = (await res.json()) as Envelope<T>
|
||||
} catch {
|
||||
// 非 JSON 响应
|
||||
}
|
||||
if (!res.ok || !body || body.code !== 0) {
|
||||
throw new ApiError(
|
||||
res.status,
|
||||
body?.code ?? -1,
|
||||
body?.message ?? `请求失败(${res.status})`,
|
||||
)
|
||||
}
|
||||
return body.data
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { request } from '@/api/client'
|
||||
|
||||
export interface PostListItem {
|
||||
id: string
|
||||
slug: string
|
||||
title: string
|
||||
category: string
|
||||
summary: string
|
||||
published_at: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface Post extends PostListItem {
|
||||
content: string
|
||||
}
|
||||
|
||||
export interface PostForm {
|
||||
slug: string
|
||||
title: string
|
||||
category: string
|
||||
summary: string
|
||||
content: string
|
||||
published: boolean
|
||||
}
|
||||
|
||||
export interface PageResult<T> {
|
||||
list: T[] | null
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
}
|
||||
|
||||
export function fetchPosts(page: number, pageSize: number, keyword: string) {
|
||||
const params = new URLSearchParams({
|
||||
page: String(page),
|
||||
page_size: String(pageSize),
|
||||
})
|
||||
if (keyword) params.set('keyword', keyword)
|
||||
return request<PageResult<PostListItem>>(`/api/admin/posts?${params}`)
|
||||
}
|
||||
|
||||
export function fetchPost(id: string) {
|
||||
return request<Post>(`/api/admin/posts/${id}`)
|
||||
}
|
||||
|
||||
export function createPost(form: PostForm) {
|
||||
return request<Post>('/api/admin/posts', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(form),
|
||||
})
|
||||
}
|
||||
|
||||
export function updatePost(id: string, form: PostForm) {
|
||||
return request<Post>(`/api/admin/posts/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(form),
|
||||
})
|
||||
}
|
||||
|
||||
export function deletePost(id: string) {
|
||||
return request<null>(`/api/admin/posts/${id}`, { method: 'DELETE' })
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Link, Outlet, useNavigate } from 'react-router-dom'
|
||||
import { LogOut, Moon, Sun } from 'lucide-react'
|
||||
import { logout } from '@/api/auth'
|
||||
import { useTheme } from '@/components/theme-provider'
|
||||
import { LogoMark } from '@/components/logo'
|
||||
|
||||
export function AdminLayout() {
|
||||
const navigate = useNavigate()
|
||||
const { resolved, setTheme } = useTheme()
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col">
|
||||
<header className="sticky top-0 z-40 border-b border-hairline bg-ground/85 backdrop-blur">
|
||||
<div className="mx-auto flex h-14 max-w-5xl items-center justify-between px-6">
|
||||
<Link to="/" className="flex items-center gap-2.5 font-mono text-[14px] font-semibold">
|
||||
<LogoMark size={24} />
|
||||
sundynix <em className="not-italic text-accent">admin</em>
|
||||
</Link>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={
|
||||
resolved === 'dark' ? '切换到浅色主题' : '切换到深色主题'
|
||||
}
|
||||
onClick={() => setTheme(resolved === 'dark' ? 'light' : 'dark')}
|
||||
className="flex size-8 items-center justify-center rounded-full border border-hairline-strong text-ink-2 transition-colors hover:border-accent hover:text-accent-ink"
|
||||
>
|
||||
{resolved === 'dark' ? (
|
||||
<Sun className="size-4" />
|
||||
) : (
|
||||
<Moon className="size-4" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
logout()
|
||||
navigate('/login')
|
||||
}}
|
||||
className="flex items-center gap-1.5 rounded-[7px] border border-hairline-strong px-3 py-1.5 text-[13px] text-ink-2 transition-colors hover:border-accent hover:text-accent-ink"
|
||||
>
|
||||
<LogOut className="size-3.5" /> 退出
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main className="mx-auto w-full max-w-5xl flex-1 px-6 py-8">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
interface LogoMarkProps {
|
||||
size?: number
|
||||
className?: string
|
||||
}
|
||||
|
||||
/** 品牌 Logo:霓虹数据流 S(与 web 同源,admin 挂 /admin base) */
|
||||
export function LogoMark({ size = 28, className }: LogoMarkProps) {
|
||||
return (
|
||||
<img
|
||||
src={`${import.meta.env.BASE_URL}logo-mark.webp`}
|
||||
width={size}
|
||||
height={size}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className={className}
|
||||
style={{ borderRadius: '22%' }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from 'react'
|
||||
|
||||
type Theme = 'light' | 'dark' | 'system'
|
||||
|
||||
interface ThemeContextValue {
|
||||
theme: Theme
|
||||
resolved: 'light' | 'dark'
|
||||
setTheme: (t: Theme) => void
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextValue | null>(null)
|
||||
|
||||
const STORAGE_KEY = 'sundynix-theme'
|
||||
|
||||
function systemTheme(): 'light' | 'dark' {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
? 'dark'
|
||||
: 'light'
|
||||
}
|
||||
|
||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
const [theme, setThemeState] = useState<Theme>(
|
||||
() => (localStorage.getItem(STORAGE_KEY) as Theme) ?? 'system',
|
||||
)
|
||||
const [resolved, setResolved] = useState<'light' | 'dark'>(() =>
|
||||
theme === 'system' ? systemTheme() : theme,
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const apply = () => {
|
||||
const r = theme === 'system' ? systemTheme() : theme
|
||||
setResolved(r)
|
||||
document.documentElement.classList.toggle('dark', r === 'dark')
|
||||
}
|
||||
apply()
|
||||
const mq = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
mq.addEventListener('change', apply)
|
||||
return () => mq.removeEventListener('change', apply)
|
||||
}, [theme])
|
||||
|
||||
const setTheme = (t: Theme) => {
|
||||
localStorage.setItem(STORAGE_KEY, t)
|
||||
setThemeState(t)
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, resolved, setTheme }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const ctx = useContext(ThemeContext)
|
||||
if (!ctx) throw new Error('useTheme must be used within ThemeProvider')
|
||||
return ctx
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
/* ── 设计 token(品牌改版 v2:跟随 logo,与 web 保持一致)── */
|
||||
:root {
|
||||
--ground: #f5f7f9;
|
||||
--surface: #ffffff;
|
||||
--ink: #151c24;
|
||||
--ink-2: #526069;
|
||||
--ink-3: #8595a0;
|
||||
--hairline: #e0e7ec;
|
||||
--hairline-strong: #c6d2da;
|
||||
--accent: #0284c7;
|
||||
--accent-ink: #075985;
|
||||
--accent-soft: #e0f2fe;
|
||||
--code-bg: #ebf1f5;
|
||||
--term-bg: #0e1620;
|
||||
--term-ink: #c9e4f5;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--ground: #0b1117;
|
||||
--surface: #111925;
|
||||
--ink: #e6edf3;
|
||||
--ink-2: #93a6b4;
|
||||
--ink-3: #62737f;
|
||||
--hairline: #1f2c38;
|
||||
--hairline-strong: #32434f;
|
||||
--accent: #38bdf8;
|
||||
--accent-ink: #7dd3fc;
|
||||
--accent-soft: #0b3049;
|
||||
--code-bg: #14202b;
|
||||
--term-bg: #0a121b;
|
||||
--term-ink: #bfe3f7;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-ground: var(--ground);
|
||||
--color-surface: var(--surface);
|
||||
--color-ink: var(--ink);
|
||||
--color-ink-2: var(--ink-2);
|
||||
--color-ink-3: var(--ink-3);
|
||||
--color-hairline: var(--hairline);
|
||||
--color-hairline-strong: var(--hairline-strong);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-ink: var(--accent-ink);
|
||||
--color-accent-soft: var(--accent-soft);
|
||||
--color-code: var(--code-bg);
|
||||
--color-term: var(--term-bg);
|
||||
--color-term-ink: var(--term-ink);
|
||||
|
||||
--font-sans: -apple-system, "PingFang SC", "Hiragino Sans GB",
|
||||
"Microsoft YaHei", "Noto Sans SC", sans-serif;
|
||||
--font-mono: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, Consolas,
|
||||
monospace;
|
||||
}
|
||||
|
||||
html {
|
||||
background: var(--ground);
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-ground text-ink font-sans antialiased;
|
||||
font-size: 16px;
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent-ink);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import { ThemeProvider } from '@/components/theme-provider'
|
||||
import App from '@/App'
|
||||
import '@/index.css'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<ThemeProvider>
|
||||
<BrowserRouter basename="/admin">
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</ThemeProvider>
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import { Navigate, useNavigate } from 'react-router-dom'
|
||||
import { getToken } from '@/api/client'
|
||||
import { login } from '@/api/auth'
|
||||
import { LogoMark } from '@/components/logo'
|
||||
|
||||
export default function LoginPage() {
|
||||
const navigate = useNavigate()
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
if (getToken()) return <Navigate to="/" replace />
|
||||
|
||||
const onSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setLoading(true)
|
||||
try {
|
||||
await login(username, password)
|
||||
navigate('/')
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '登录失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center px-6">
|
||||
<form
|
||||
onSubmit={onSubmit}
|
||||
className="w-full max-w-[360px] rounded-xl border border-hairline bg-surface p-8"
|
||||
>
|
||||
<p className="mb-1 flex items-center gap-2 font-mono text-[13px] font-semibold">
|
||||
<LogoMark size={22} />
|
||||
sundynix <em className="not-italic text-accent">admin</em>
|
||||
</p>
|
||||
<h1 className="mb-6 text-[20px] font-[650] tracking-[-0.01em]">
|
||||
登录内容管理
|
||||
</h1>
|
||||
|
||||
<label className="mb-4 block">
|
||||
<span className="mb-1.5 block text-[13px] text-ink-2">用户名</span>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
autoComplete="username"
|
||||
required
|
||||
className="w-full rounded-lg border border-hairline-strong bg-ground px-3.5 py-2 text-[14.5px] outline-none transition-colors focus:border-accent"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="mb-5 block">
|
||||
<span className="mb-1.5 block text-[13px] text-ink-2">密码</span>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
required
|
||||
className="w-full rounded-lg border border-hairline-strong bg-ground px-3.5 py-2 text-[14.5px] outline-none transition-colors focus:border-accent"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{error && (
|
||||
<p className="mb-4 text-[13px] text-red-600 dark:text-red-400">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full rounded-lg bg-accent py-2.5 text-[14.5px] font-medium text-ground transition-opacity hover:opacity-90 disabled:opacity-60"
|
||||
>
|
||||
{loading ? '登录中…' : '登录'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import { useEffect, useState, type FormEvent } from 'react'
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||
import { ArrowLeft } from 'lucide-react'
|
||||
import {
|
||||
createPost,
|
||||
fetchPost,
|
||||
updatePost,
|
||||
type PostForm,
|
||||
} from '@/api/posts'
|
||||
|
||||
const EMPTY: PostForm = {
|
||||
slug: '',
|
||||
title: '',
|
||||
category: '',
|
||||
summary: '',
|
||||
content: '',
|
||||
published: false,
|
||||
}
|
||||
|
||||
export default function PostEditPage() {
|
||||
const { id } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const isEdit = Boolean(id)
|
||||
|
||||
const [form, setForm] = useState<PostForm>(EMPTY)
|
||||
const [loading, setLoading] = useState(isEdit)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return
|
||||
let cancelled = false
|
||||
fetchPost(id)
|
||||
.then((post) => {
|
||||
if (cancelled) return
|
||||
setForm({
|
||||
slug: post.slug,
|
||||
title: post.title,
|
||||
category: post.category,
|
||||
summary: post.summary,
|
||||
content: post.content ?? '',
|
||||
published: post.published_at !== null,
|
||||
})
|
||||
setLoading(false)
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
if (cancelled) return
|
||||
setError(err.message)
|
||||
setLoading(false)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [id])
|
||||
|
||||
const set = <K extends keyof PostForm>(key: K, value: PostForm[K]) =>
|
||||
setForm((f) => ({ ...f, [key]: value }))
|
||||
|
||||
const onSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
setSaving(true)
|
||||
setError('')
|
||||
try {
|
||||
if (isEdit && id) {
|
||||
await updatePost(id, form)
|
||||
} else {
|
||||
await createPost(form)
|
||||
}
|
||||
navigate('/')
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '保存失败')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const inputCls =
|
||||
'w-full rounded-lg border border-hairline-strong bg-ground px-3.5 py-2 text-[14px] outline-none transition-colors focus:border-accent'
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Link
|
||||
to="/"
|
||||
className="mb-6 inline-flex items-center gap-1.5 font-mono text-[12.5px] tracking-[0.08em] text-ink-3 transition-colors hover:text-accent-ink"
|
||||
>
|
||||
<ArrowLeft className="size-3.5" /> 文章列表
|
||||
</Link>
|
||||
|
||||
<h1 className="mb-6 text-[22px] font-[650] tracking-[-0.01em]">
|
||||
{isEdit ? '编辑文章' : '新建文章'}
|
||||
</h1>
|
||||
|
||||
{loading ? (
|
||||
<div className="space-y-4">
|
||||
<div className="h-10 animate-pulse rounded-lg bg-code" />
|
||||
<div className="h-64 animate-pulse rounded-lg bg-code" />
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={onSubmit} className="space-y-5">
|
||||
<div className="grid grid-cols-1 gap-5 md:grid-cols-2">
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-[13px] text-ink-2">标题 *</span>
|
||||
<input
|
||||
type="text"
|
||||
value={form.title}
|
||||
onChange={(e) => set('title', e.target.value)}
|
||||
required
|
||||
className={inputCls}
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-[13px] text-ink-2">
|
||||
Slug *(URL 标识,如 my-first-post)
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={form.slug}
|
||||
onChange={(e) => set('slug', e.target.value)}
|
||||
required
|
||||
pattern="[a-z0-9]+(-[a-z0-9]+)*"
|
||||
title="小写字母、数字,用 - 连接"
|
||||
className={`${inputCls} font-mono`}
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-[13px] text-ink-2">
|
||||
分类(如 RELEASE / ENGINEERING)
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={form.category}
|
||||
onChange={(e) => set('category', e.target.value.toUpperCase())}
|
||||
className={`${inputCls} font-mono`}
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-[13px] text-ink-2">摘要</span>
|
||||
<input
|
||||
type="text"
|
||||
value={form.summary}
|
||||
onChange={(e) => set('summary', e.target.value)}
|
||||
className={inputCls}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-[13px] text-ink-2">
|
||||
正文(Markdown)
|
||||
</span>
|
||||
<textarea
|
||||
value={form.content}
|
||||
onChange={(e) => set('content', e.target.value)}
|
||||
rows={18}
|
||||
spellCheck={false}
|
||||
className={`${inputCls} resize-y font-mono text-[13.5px] leading-relaxed`}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="flex items-center justify-between border-t border-hairline pt-5">
|
||||
<label className="flex cursor-pointer items-center gap-2.5 text-[14px]">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.published}
|
||||
onChange={(e) => set('published', e.target.checked)}
|
||||
className="size-4 accent-(--accent)"
|
||||
/>
|
||||
发布到用户端
|
||||
<span className="text-[12.5px] text-ink-3">
|
||||
(不勾选保存为草稿)
|
||||
</span>
|
||||
</label>
|
||||
<div className="flex items-center gap-3">
|
||||
{error && (
|
||||
<span className="text-[13px] text-red-600 dark:text-red-400">
|
||||
{error}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="rounded-lg bg-accent px-6 py-2 text-[14px] font-medium text-ground transition-opacity hover:opacity-90 disabled:opacity-60"
|
||||
>
|
||||
{saving ? '保存中…' : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ChevronLeft, ChevronRight, Plus, Search, Trash2 } from 'lucide-react'
|
||||
import {
|
||||
deletePost,
|
||||
fetchPosts,
|
||||
type PageResult,
|
||||
type PostListItem,
|
||||
} from '@/api/posts'
|
||||
|
||||
const PAGE_SIZE = 10
|
||||
|
||||
export default function PostsListPage() {
|
||||
const [page, setPage] = useState(1)
|
||||
const [keyword, setKeyword] = useState('')
|
||||
const [input, setInput] = useState('')
|
||||
const [result, setResult] = useState<PageResult<PostListItem> | null>(null)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const load = useCallback(() => {
|
||||
fetchPosts(page, PAGE_SIZE, keyword)
|
||||
.then((r) => {
|
||||
setResult(r)
|
||||
setError('')
|
||||
})
|
||||
.catch((err: Error) => setError(err.message))
|
||||
}, [page, keyword])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
const onDelete = async (post: PostListItem) => {
|
||||
if (!window.confirm(`确认删除「${post.title}」?此操作不可恢复。`)) return
|
||||
try {
|
||||
await deletePost(post.id)
|
||||
load()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
const totalPages = result ? Math.max(1, Math.ceil(result.total / PAGE_SIZE)) : 1
|
||||
const list = result?.list ?? []
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6 flex flex-wrap items-center justify-between gap-3">
|
||||
<h1 className="text-[22px] font-[650] tracking-[-0.01em]">文章管理</h1>
|
||||
<div className="flex items-center gap-3">
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
setPage(1)
|
||||
setKeyword(input)
|
||||
}}
|
||||
className="flex items-center gap-2 rounded-lg border border-hairline-strong px-3 py-1.5"
|
||||
>
|
||||
<Search className="size-4 text-ink-3" />
|
||||
<input
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder="搜标题 / slug / 分类"
|
||||
className="w-44 bg-transparent text-[13.5px] outline-none placeholder:text-ink-3"
|
||||
/>
|
||||
</form>
|
||||
<Link
|
||||
to="/posts/new"
|
||||
className="flex items-center gap-1.5 rounded-lg bg-accent px-4 py-2 text-[13.5px] font-medium text-ground transition-opacity hover:opacity-90"
|
||||
>
|
||||
<Plus className="size-4" /> 新建文章
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="mb-4 rounded-lg border border-hairline bg-surface px-4 py-3 text-[13.5px] text-ink-2">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="overflow-x-auto rounded-xl border border-hairline bg-surface">
|
||||
<table className="w-full text-left text-[13.5px]">
|
||||
<thead>
|
||||
<tr className="border-b border-hairline font-mono text-[11px] tracking-[0.1em] text-ink-3">
|
||||
<th className="px-5 py-3 font-medium">标题</th>
|
||||
<th className="px-4 py-3 font-medium">SLUG</th>
|
||||
<th className="px-4 py-3 font-medium">分类</th>
|
||||
<th className="px-4 py-3 font-medium">状态</th>
|
||||
<th className="px-4 py-3 font-medium">更新时间</th>
|
||||
<th className="px-4 py-3 text-right font-medium">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{list.map((post) => (
|
||||
<tr
|
||||
key={post.id}
|
||||
className="border-b border-hairline last:border-b-0 hover:bg-ground"
|
||||
>
|
||||
<td className="max-w-[280px] truncate px-5 py-3 font-medium">
|
||||
<Link
|
||||
to={`/posts/${post.id}`}
|
||||
className="transition-colors hover:text-accent-ink"
|
||||
>
|
||||
{post.title}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-[12px] text-ink-3">
|
||||
{post.slug}
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-[11.5px] text-ink-2">
|
||||
{post.category || '—'}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{post.published_at ? (
|
||||
<span className="rounded-full bg-accent-soft px-2.5 py-0.5 text-[12px] text-accent-ink">
|
||||
已发布
|
||||
</span>
|
||||
) : (
|
||||
<span className="rounded-full bg-code px-2.5 py-0.5 text-[12px] text-ink-3">
|
||||
草稿
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-[12px] tabular-nums text-ink-3">
|
||||
{post.updated_at.slice(0, 10)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`删除 ${post.title}`}
|
||||
onClick={() => onDelete(post)}
|
||||
className="rounded p-1.5 text-ink-3 transition-colors hover:text-red-600 dark:hover:text-red-400"
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{result && list.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-5 py-10 text-center text-ink-3">
|
||||
{keyword ? '没有匹配的文章' : '还没有文章,点右上角新建'}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{result && result.total > PAGE_SIZE && (
|
||||
<div className="mt-4 flex items-center justify-end gap-3 text-[13px] text-ink-2">
|
||||
<span className="font-mono tabular-nums">
|
||||
{page} / {totalPages} · 共 {result.total} 篇
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="上一页"
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage((p) => p - 1)}
|
||||
className="rounded border border-hairline-strong p-1.5 disabled:opacity-40"
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="下一页"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
className="rounded border border-hairline-strong p-1.5 disabled:opacity-40"
|
||||
>
|
||||
<ChevronRight className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023", "DOM"],
|
||||
"module": "esnext",
|
||||
"types": ["vite/client"],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
"allowArbitraryExtensions": true,
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023"],
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"module": "nodenext",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import path from 'node:path'
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
base: '/admin/',
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5174,
|
||||
proxy: {
|
||||
'/api': 'http://localhost:8090',
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
# 部署说明
|
||||
|
||||
## 拓扑
|
||||
|
||||
```
|
||||
push/merge main ─▶ Gitea(192.168.100.125:3000)
|
||||
│ 触发 .gitea/workflows/deploy.yml
|
||||
▼
|
||||
act_runner(192.168.100.128)
|
||||
│ docker build → save → scp
|
||||
▼
|
||||
部署机(192.168.100.132) /home/workspace/sundynix-site
|
||||
│ docker load → compose up → 容器 :8090
|
||||
│ │ 连
|
||||
│ MySQL(192.168.100.127:3307)
|
||||
▼
|
||||
内网穿透 ──▶ 公网服务器 nginx ──▶ https://site.sundynix.cn
|
||||
```
|
||||
|
||||
## 一次性准备
|
||||
|
||||
### 1. Gitea 仓库 Secrets(仓库 → Settings → Actions → Secrets)
|
||||
|
||||
| Secret | 值 |
|
||||
|--------|-----|
|
||||
| `DEPLOY_HOST` | `192.168.100.132` |
|
||||
| `DEPLOY_USER` | `root` |
|
||||
| `DEPLOY_PASSWORD` | `sundynix` |
|
||||
|
||||
### 2. 部署机 132 准备
|
||||
|
||||
```bash
|
||||
# 装好 docker + compose 插件后:
|
||||
mkdir -p /home/workspace/sundynix-site
|
||||
cd /home/workspace/sundynix-site
|
||||
# 放置生产配置(参考仓库 .env.production.example)
|
||||
vi .env # 改 admin 密码、JWT 密钥
|
||||
```
|
||||
|
||||
`.env` 关键项见 [.env.production.example](../.env.production.example)。数据库指向内网 `192.168.100.127:3307/sundynix_site`(库不存在会自动建表 + 种子)。
|
||||
|
||||
### 3. act_runner 128 要求
|
||||
|
||||
- 能访问 docker daemon(`docker build/save` 可用)
|
||||
- 能 ssh 到 132(workflow 用 sshpass 走密码)
|
||||
|
||||
### 4. 公网服务器 nginx
|
||||
|
||||
1. 内网穿透把 132:8090 映射到公网机本地端口
|
||||
2. 用 [nginx-site.sundynix.cn.conf](nginx-site.sundynix.cn.conf) 配置反代,改 `upstream` 为穿透实际端口
|
||||
3. 证书:`certbot certonly --webroot -w /var/www/certbot -d site.sundynix.cn`
|
||||
|
||||
## 日常
|
||||
|
||||
- 提交或合并到 `main` 分支自动构建部署;也可在 Gitea Actions 页手动 `workflow_dispatch`
|
||||
- 日常开发在 `dev` 分支,PR 合并进 `main` 才触发上线
|
||||
- 回滚:132 上 `docker tag` 保留的旧镜像,或重跑上一次成功的 commit
|
||||
- 查日志:`docker logs -f sundynix-site`
|
||||
- 本地手动出包(不走 CI):仓库根 `docker build -t sundynix-site:latest .`
|
||||
@@ -0,0 +1,65 @@
|
||||
# ───────────────────────────────────────────────────────────
|
||||
# 公网服务器上的 nginx 配置
|
||||
# site.sundynix.cn → 内网穿透隧道 → 192.168.100.132:8090 容器
|
||||
#
|
||||
# upstream 里的地址是「穿透隧道在公网服务器这一侧的入口」:
|
||||
# frp 场景:frps 把 132:8090 映射到公网机 127.0.0.1:<某端口>
|
||||
# nps 场景:同理,填映射后的本地端口
|
||||
# 请把 127.0.0.1:8090 改成你穿透实际暴露的地址:端口。
|
||||
# 放到 /etc/nginx/conf.d/site.sundynix.cn.conf,nginx -t && nginx -s reload
|
||||
# ───────────────────────────────────────────────────────────
|
||||
|
||||
upstream sundynix_site {
|
||||
server 127.0.0.1:8090; # ← 改成穿透隧道的本地入口
|
||||
keepalive 16;
|
||||
}
|
||||
|
||||
# HTTP:证书申请放行 + 其余跳 HTTPS
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name site.sundynix.cn;
|
||||
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/certbot;
|
||||
}
|
||||
location / {
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
# HTTPS:反代到穿透隧道
|
||||
server {
|
||||
listen 443 ssl;
|
||||
listen [::]:443 ssl;
|
||||
http2 on;
|
||||
server_name site.sundynix.cn;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/site.sundynix.cn/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/site.sundynix.cn/privkey.pem;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
|
||||
# 管理端 markdown 正文可能较大
|
||||
client_max_body_size 10m;
|
||||
|
||||
# 静态资源带 hash,可长缓存(SPA 的 index.html 不缓存,由后端控制)
|
||||
location /assets/ {
|
||||
proxy_pass http://sundynix_site;
|
||||
proxy_set_header Host $host;
|
||||
expires 30d;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://sundynix_site;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Connection "";
|
||||
proxy_read_timeout 60s;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
# 部署机 192.168.100.132:/home/workspace/sundynix-site/ 使用
|
||||
# 镜像由 CI 用 docker save + scp + docker load 送入,标签固定 sundynix-site:latest
|
||||
services:
|
||||
site:
|
||||
image: sundynix-site:latest
|
||||
container_name: sundynix-site
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env
|
||||
ports:
|
||||
# 暴露给本机内网穿透客户端;公网 nginx 经隧道回源到这里
|
||||
- "8090:8090"
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8090/api/healthz"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
@@ -0,0 +1,65 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
|
||||
"git.sundynix.cn/Blizzard/sundynix-site/server/internal/pkg/auth"
|
||||
"git.sundynix.cn/Blizzard/sundynix-site/server/internal/pkg/idgen"
|
||||
"git.sundynix.cn/Blizzard/sundynix-site/server/internal/router"
|
||||
"git.sundynix.cn/Blizzard/sundynix-site/server/internal/store"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// 依次尝试仓库根 / server 目录下的 .env(已存在的环境变量优先)
|
||||
_ = godotenv.Load(".env")
|
||||
_ = godotenv.Load("../.env")
|
||||
|
||||
// 未配置 SUNDYNIX_DB 时回落到本地 SQLite,方便零依赖起步
|
||||
dsn := envOr("SUNDYNIX_DB", "sundynix-site.db")
|
||||
// 8080 常被 agentix gateway 占用,site 默认 8090
|
||||
addr := envOr("SUNDYNIX_ADDR", ":8090")
|
||||
|
||||
if err := idgen.Init(); err != nil {
|
||||
log.Fatalf("初始化雪花节点失败: %v", err)
|
||||
}
|
||||
auth.Init()
|
||||
|
||||
db, err := store.Open(dsn)
|
||||
if err != nil {
|
||||
log.Fatalf("打开数据库失败: %v", err)
|
||||
}
|
||||
|
||||
r, err := router.New(db)
|
||||
if err != nil {
|
||||
log.Fatalf("初始化路由失败: %v", err)
|
||||
}
|
||||
|
||||
log.Printf("sundynix-site 启动于 %s (db=%s)", addr, maskDSN(dsn))
|
||||
if err := r.Run(addr); err != nil {
|
||||
log.Fatalf("服务退出: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func envOr(key, fallback string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// maskDSN 日志脱敏:user:pass@tcp(...) → user:***@tcp(...)
|
||||
func maskDSN(dsn string) string {
|
||||
at := strings.Index(dsn, "@")
|
||||
if at < 0 {
|
||||
return dsn
|
||||
}
|
||||
colon := strings.Index(dsn[:at], ":")
|
||||
if colon < 0 {
|
||||
return dsn
|
||||
}
|
||||
return dsn[:colon+1] + "***" + dsn[at:]
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
module git.sundynix.cn/Blizzard/sundynix-site/server
|
||||
|
||||
go 1.26.4
|
||||
|
||||
require (
|
||||
github.com/bwmarrin/snowflake v0.3.0
|
||||
github.com/gin-gonic/gin v1.12.0
|
||||
github.com/glebarez/sqlite v1.11.0
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/joho/godotenv v1.5.1
|
||||
gorm.io/driver/mysql v1.6.0
|
||||
gorm.io/gorm v1.31.2
|
||||
)
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
github.com/bytedance/sonic v1.15.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/glebarez/go-sqlite v1.21.2 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
||||
github.com/go-sql-driver/mysql v1.8.1 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.59.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
||||
golang.org/x/arch v0.22.0 // indirect
|
||||
golang.org/x/crypto v0.48.0 // indirect
|
||||
golang.org/x/net v0.51.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
google.golang.org/protobuf v1.36.10 // indirect
|
||||
modernc.org/libc v1.22.5 // indirect
|
||||
modernc.org/mathutil v1.5.0 // indirect
|
||||
modernc.org/memory v1.5.0 // indirect
|
||||
modernc.org/sqlite v1.23.1 // indirect
|
||||
)
|
||||
@@ -0,0 +1,132 @@
|
||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
github.com/bwmarrin/snowflake v0.3.0 h1:xm67bEhkKh6ij1790JB83OujPR5CzNe8QuQqAgISZN0=
|
||||
github.com/bwmarrin/snowflake v0.3.0/go.mod h1:NdZxfVWX+oR6y2K0o6qAYv6gIOP9rjG0/E9WsDpxqwE=
|
||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
||||
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
||||
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
||||
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
|
||||
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
|
||||
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
|
||||
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
|
||||
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
||||
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
||||
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
|
||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
|
||||
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
|
||||
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
||||
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
|
||||
gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=
|
||||
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
|
||||
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
|
||||
gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
|
||||
gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
|
||||
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
|
||||
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
|
||||
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
|
||||
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
|
||||
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
|
||||
modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM=
|
||||
modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
|
||||
@@ -0,0 +1,58 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.sundynix.cn/Blizzard/sundynix-site/server/internal/pkg/auth"
|
||||
"git.sundynix.cn/Blizzard/sundynix-site/server/internal/pkg/resp"
|
||||
)
|
||||
|
||||
// AdminAuthHandler 管理端登录。
|
||||
// 账号密码走环境变量 SUNDYNIX_ADMIN_USER / SUNDYNIX_ADMIN_PASS。
|
||||
type AdminAuthHandler struct {
|
||||
username string
|
||||
password string
|
||||
}
|
||||
|
||||
func NewAdminAuthHandler() *AdminAuthHandler {
|
||||
u := os.Getenv("SUNDYNIX_ADMIN_USER")
|
||||
p := os.Getenv("SUNDYNIX_ADMIN_PASS")
|
||||
if u == "" {
|
||||
u = "admin"
|
||||
}
|
||||
if p == "" {
|
||||
p = "admin123"
|
||||
log.Println("[WARN] SUNDYNIX_ADMIN_PASS 未设置,使用开发默认密码 admin123,生产环境务必配置")
|
||||
}
|
||||
return &AdminAuthHandler{username: u, password: p}
|
||||
}
|
||||
|
||||
type loginReq struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
}
|
||||
|
||||
// Login POST /api/admin/login
|
||||
func (h *AdminAuthHandler) Login(c *gin.Context) {
|
||||
var body loginReq
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
resp.BadRequest(c, "用户名和密码不能为空")
|
||||
return
|
||||
}
|
||||
userOK := subtle.ConstantTimeCompare([]byte(body.Username), []byte(h.username)) == 1
|
||||
passOK := subtle.ConstantTimeCompare([]byte(body.Password), []byte(h.password)) == 1
|
||||
if !userOK || !passOK {
|
||||
resp.Unauthorized(c, "用户名或密码错误")
|
||||
return
|
||||
}
|
||||
token, err := auth.Sign(body.Username)
|
||||
if err != nil {
|
||||
resp.ServerError(c, "签发 token 失败")
|
||||
return
|
||||
}
|
||||
resp.OK(c, gin.H{"token": token, "username": body.Username})
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"git.sundynix.cn/Blizzard/sundynix-site/server/internal/model"
|
||||
"git.sundynix.cn/Blizzard/sundynix-site/server/internal/pkg/req"
|
||||
"git.sundynix.cn/Blizzard/sundynix-site/server/internal/pkg/resp"
|
||||
)
|
||||
|
||||
// AdminPostHandler 管理端文章 CRUD(含草稿)。
|
||||
type AdminPostHandler struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewAdminPostHandler(db *gorm.DB) *AdminPostHandler {
|
||||
return &AdminPostHandler{db: db}
|
||||
}
|
||||
|
||||
type postForm struct {
|
||||
Slug string `json:"slug" binding:"required"`
|
||||
Title string `json:"title" binding:"required"`
|
||||
Category string `json:"category"`
|
||||
Summary string `json:"summary"`
|
||||
Content string `json:"content"`
|
||||
Published bool `json:"published"`
|
||||
}
|
||||
|
||||
// List GET /api/admin/posts — 分页 + 关键词,含草稿
|
||||
func (h *AdminPostHandler) List(c *gin.Context) {
|
||||
var q req.PageQuery
|
||||
if err := c.ShouldBindQuery(&q); err != nil {
|
||||
resp.BadRequest(c, "分页参数不合法")
|
||||
return
|
||||
}
|
||||
q.Normalize()
|
||||
|
||||
tx := h.db.Model(&model.Post{})
|
||||
if q.Keyword != "" {
|
||||
kw := "%" + q.Keyword + "%"
|
||||
tx = tx.Where("title LIKE ? OR slug LIKE ? OR category LIKE ?", kw, kw, kw)
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := tx.Count(&total).Error; err != nil {
|
||||
resp.ServerError(c, "查询失败")
|
||||
return
|
||||
}
|
||||
var items []model.PostListItem
|
||||
err := tx.Order("created_at DESC").
|
||||
Offset(q.Offset()).Limit(q.PageSize).
|
||||
Find(&items).Error
|
||||
if err != nil {
|
||||
resp.ServerError(c, "查询失败")
|
||||
return
|
||||
}
|
||||
resp.Page(c, items, total, q.Page, q.PageSize)
|
||||
}
|
||||
|
||||
// Get GET /api/admin/posts/:id
|
||||
func (h *AdminPostHandler) Get(c *gin.Context) {
|
||||
var post model.Post
|
||||
err := h.db.First(&post, "id = ?", c.Param("id")).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
resp.NotFound(c, "文章不存在")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
resp.ServerError(c, "查询失败")
|
||||
return
|
||||
}
|
||||
resp.OK(c, post)
|
||||
}
|
||||
|
||||
// Create POST /api/admin/posts
|
||||
func (h *AdminPostHandler) Create(c *gin.Context) {
|
||||
var form postForm
|
||||
if err := c.ShouldBindJSON(&form); err != nil {
|
||||
resp.BadRequest(c, "slug 和标题不能为空")
|
||||
return
|
||||
}
|
||||
post := model.Post{
|
||||
Slug: form.Slug,
|
||||
Title: form.Title,
|
||||
Category: form.Category,
|
||||
Summary: form.Summary,
|
||||
Content: form.Content,
|
||||
}
|
||||
if form.Published {
|
||||
now := time.Now()
|
||||
post.PublishedAt = &now
|
||||
}
|
||||
if err := h.db.Create(&post).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrDuplicatedKey) {
|
||||
resp.BadRequest(c, "slug 已存在")
|
||||
return
|
||||
}
|
||||
resp.ServerError(c, "创建失败:"+err.Error())
|
||||
return
|
||||
}
|
||||
resp.OK(c, post)
|
||||
}
|
||||
|
||||
// Update PUT /api/admin/posts/:id
|
||||
func (h *AdminPostHandler) Update(c *gin.Context) {
|
||||
var form postForm
|
||||
if err := c.ShouldBindJSON(&form); err != nil {
|
||||
resp.BadRequest(c, "slug 和标题不能为空")
|
||||
return
|
||||
}
|
||||
var post model.Post
|
||||
err := h.db.First(&post, "id = ?", c.Param("id")).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
resp.NotFound(c, "文章不存在")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
resp.ServerError(c, "查询失败")
|
||||
return
|
||||
}
|
||||
|
||||
post.Slug = form.Slug
|
||||
post.Title = form.Title
|
||||
post.Category = form.Category
|
||||
post.Summary = form.Summary
|
||||
post.Content = form.Content
|
||||
// 发布状态切换:首次发布记时间,撤回清空,重复发布保留原时间
|
||||
if form.Published && post.PublishedAt == nil {
|
||||
now := time.Now()
|
||||
post.PublishedAt = &now
|
||||
} else if !form.Published {
|
||||
post.PublishedAt = nil
|
||||
}
|
||||
|
||||
if err := h.db.Save(&post).Error; err != nil {
|
||||
resp.ServerError(c, "更新失败:"+err.Error())
|
||||
return
|
||||
}
|
||||
resp.OK(c, post)
|
||||
}
|
||||
|
||||
// Delete DELETE /api/admin/posts/:id
|
||||
func (h *AdminPostHandler) Delete(c *gin.Context) {
|
||||
res := h.db.Delete(&model.Post{}, "id = ?", c.Param("id"))
|
||||
if res.Error != nil {
|
||||
resp.ServerError(c, "删除失败")
|
||||
return
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
resp.NotFound(c, "文章不存在")
|
||||
return
|
||||
}
|
||||
resp.OK(c, nil)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"git.sundynix.cn/Blizzard/sundynix-site/server/internal/model"
|
||||
"git.sundynix.cn/Blizzard/sundynix-site/server/internal/pkg/resp"
|
||||
)
|
||||
|
||||
// PostHandler 面向用户端的公开文章接口。
|
||||
type PostHandler struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewPostHandler(db *gorm.DB) *PostHandler {
|
||||
return &PostHandler{db: db}
|
||||
}
|
||||
|
||||
// List GET /api/posts — 已发布文章列表(不含正文)
|
||||
func (h *PostHandler) List(c *gin.Context) {
|
||||
var items []model.PostListItem
|
||||
err := h.db.Model(&model.Post{}).
|
||||
Where("published_at IS NOT NULL").
|
||||
Order("published_at DESC").
|
||||
Find(&items).Error
|
||||
if err != nil {
|
||||
resp.ServerError(c, "查询失败")
|
||||
return
|
||||
}
|
||||
resp.OK(c, items)
|
||||
}
|
||||
|
||||
// Get GET /api/posts/:slug — 文章详情(含 Markdown 正文)
|
||||
func (h *PostHandler) Get(c *gin.Context) {
|
||||
var post model.Post
|
||||
err := h.db.
|
||||
Where("slug = ? AND published_at IS NOT NULL", c.Param("slug")).
|
||||
First(&post).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
resp.NotFound(c, "文章不存在")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
resp.ServerError(c, "查询失败")
|
||||
return
|
||||
}
|
||||
resp.OK(c, post)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.sundynix.cn/Blizzard/sundynix-site/server/internal/pkg/resp"
|
||||
"git.sundynix.cn/Blizzard/sundynix-site/server/internal/service"
|
||||
)
|
||||
|
||||
// ReleaseHandler 最新版本信息(代理 gitea)。
|
||||
type ReleaseHandler struct {
|
||||
svc *service.ReleaseService
|
||||
}
|
||||
|
||||
func NewReleaseHandler() *ReleaseHandler {
|
||||
return &ReleaseHandler{svc: service.NewReleaseService()}
|
||||
}
|
||||
|
||||
// Latest GET /api/releases/latest
|
||||
func (h *ReleaseHandler) Latest(c *gin.Context) {
|
||||
resp.OK(c, h.svc.Latest())
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"git.sundynix.cn/Blizzard/sundynix-site/server/internal/model"
|
||||
)
|
||||
|
||||
// RSSHandler 输出博客的 RSS 2.0 订阅源。
|
||||
type RSSHandler struct {
|
||||
db *gorm.DB
|
||||
siteURL string
|
||||
}
|
||||
|
||||
func NewRSSHandler(db *gorm.DB) *RSSHandler {
|
||||
siteURL := os.Getenv("SUNDYNIX_SITE_URL")
|
||||
if siteURL == "" {
|
||||
siteURL = "https://sundynix.com"
|
||||
}
|
||||
return &RSSHandler{db: db, siteURL: siteURL}
|
||||
}
|
||||
|
||||
type rssItem struct {
|
||||
Title string `xml:"title"`
|
||||
Link string `xml:"link"`
|
||||
GUID string `xml:"guid"`
|
||||
Description string `xml:"description"`
|
||||
Category string `xml:"category,omitempty"`
|
||||
PubDate string `xml:"pubDate"`
|
||||
}
|
||||
|
||||
type rssFeed struct {
|
||||
XMLName xml.Name `xml:"rss"`
|
||||
Version string `xml:"version,attr"`
|
||||
Channel struct {
|
||||
Title string `xml:"title"`
|
||||
Link string `xml:"link"`
|
||||
Description string `xml:"description"`
|
||||
Language string `xml:"language"`
|
||||
Items []rssItem `xml:"item"`
|
||||
} `xml:"channel"`
|
||||
}
|
||||
|
||||
// Feed GET /rss.xml
|
||||
func (h *RSSHandler) Feed(c *gin.Context) {
|
||||
var posts []model.PostListItem
|
||||
err := h.db.Model(&model.Post{}).
|
||||
Where("published_at IS NOT NULL").
|
||||
Order("published_at DESC").
|
||||
Limit(50).
|
||||
Find(&posts).Error
|
||||
if err != nil {
|
||||
c.String(http.StatusInternalServerError, "feed unavailable")
|
||||
return
|
||||
}
|
||||
|
||||
feed := rssFeed{Version: "2.0"}
|
||||
feed.Channel.Title = "sundynix agentix 博客"
|
||||
feed.Channel.Link = h.siteURL + "/blog"
|
||||
feed.Channel.Description = "事件驱动的 AI Agent 工作台 — 发布日志与工程笔记"
|
||||
feed.Channel.Language = "zh-cn"
|
||||
for _, p := range posts {
|
||||
link := h.siteURL + "/blog/" + p.Slug
|
||||
item := rssItem{
|
||||
Title: p.Title,
|
||||
Link: link,
|
||||
GUID: link,
|
||||
Description: p.Summary,
|
||||
Category: p.Category,
|
||||
}
|
||||
if p.PublishedAt != nil {
|
||||
item.PubDate = p.PublishedAt.Format(time.RFC1123Z)
|
||||
}
|
||||
feed.Channel.Items = append(feed.Channel.Items, item)
|
||||
}
|
||||
|
||||
c.Header("Content-Type", "application/rss+xml; charset=utf-8")
|
||||
c.String(http.StatusOK, xml.Header)
|
||||
if out, err := xml.MarshalIndent(feed, "", " "); err == nil {
|
||||
c.Writer.Write(out)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.sundynix.cn/Blizzard/sundynix-site/server/internal/pkg/auth"
|
||||
"git.sundynix.cn/Blizzard/sundynix-site/server/internal/pkg/resp"
|
||||
)
|
||||
|
||||
// Auth 校验 Authorization: Bearer <token>,通过后把用户名放进上下文。
|
||||
func Auth() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
h := c.GetHeader("Authorization")
|
||||
token, ok := strings.CutPrefix(h, "Bearer ")
|
||||
if !ok || token == "" {
|
||||
resp.Unauthorized(c, "未登录")
|
||||
return
|
||||
}
|
||||
username, err := auth.Parse(token)
|
||||
if err != nil {
|
||||
resp.Unauthorized(c, err.Error())
|
||||
return
|
||||
}
|
||||
c.Set("username", username)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// CORS 开发期跨域放行;生产同源部署(embed)时不会触发预检。
|
||||
func CORS() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
origin := c.GetHeader("Origin")
|
||||
if origin != "" {
|
||||
c.Header("Access-Control-Allow-Origin", origin)
|
||||
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||
c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
||||
}
|
||||
if c.Request.Method == http.MethodOptions {
|
||||
c.AbortWithStatus(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"git.sundynix.cn/Blizzard/sundynix-site/server/internal/pkg/idgen"
|
||||
)
|
||||
|
||||
// BaseModel 所有表的公共字段:字符串雪花主键 + 时间戳。
|
||||
// 列名由 GORM NamingStrategy 统一转 snake_case。
|
||||
type BaseModel struct {
|
||||
ID string `gorm:"primaryKey;size:20" json:"id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// BeforeCreate 主键为空时自动生成雪花 ID。
|
||||
func (m *BaseModel) BeforeCreate(*gorm.DB) error {
|
||||
if m.ID == "" {
|
||||
m.ID = idgen.Next()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// Post 博客文章,表名 sundynix_post(NamingStrategy 统一加前缀)。
|
||||
// Content 为 Markdown 原文,渲染交给前端;PublishedAt 为空即草稿。
|
||||
type Post struct {
|
||||
BaseModel
|
||||
Slug string `gorm:"uniqueIndex;size:128" json:"slug"`
|
||||
Title string `gorm:"size:256" json:"title"`
|
||||
Category string `gorm:"size:64;index" json:"category"`
|
||||
Summary string `gorm:"size:512" json:"summary"`
|
||||
Content string `gorm:"type:text" json:"content,omitempty"`
|
||||
PublishedAt *time.Time `gorm:"index" json:"published_at"`
|
||||
}
|
||||
|
||||
// PostListItem 列表项投影,不带正文。
|
||||
type PostListItem struct {
|
||||
ID string `json:"id"`
|
||||
Slug string `json:"slug"`
|
||||
Title string `json:"title"`
|
||||
Category string `json:"category"`
|
||||
Summary string `json:"summary"`
|
||||
PublishedAt *time.Time `json:"published_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
// Package auth JWT 签发与校验。
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
var secret []byte
|
||||
|
||||
// Init 读取 JWT 密钥;未配置时使用开发默认值并告警。
|
||||
func Init() {
|
||||
s := os.Getenv("SUNDYNIX_JWT_SECRET")
|
||||
if s == "" {
|
||||
s = "sundynix-dev-secret-change-me"
|
||||
log.Println("[WARN] SUNDYNIX_JWT_SECRET 未设置,使用开发默认密钥,生产环境务必配置")
|
||||
}
|
||||
secret = []byte(s)
|
||||
}
|
||||
|
||||
// Sign 为用户名签发 24h 有效期的 token。
|
||||
func Sign(username string) (string, error) {
|
||||
claims := jwt.RegisteredClaims{
|
||||
Subject: username,
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)),
|
||||
Issuer: "sundynix-site",
|
||||
}
|
||||
return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(secret)
|
||||
}
|
||||
|
||||
// Parse 校验 token 并返回用户名。
|
||||
func Parse(token string) (string, error) {
|
||||
t, err := jwt.ParseWithClaims(token, &jwt.RegisteredClaims{}, func(t *jwt.Token) (any, error) {
|
||||
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, errors.New("非法签名算法")
|
||||
}
|
||||
return secret, nil
|
||||
})
|
||||
if err != nil || !t.Valid {
|
||||
return "", errors.New("token 无效或已过期")
|
||||
}
|
||||
claims, ok := t.Claims.(*jwt.RegisteredClaims)
|
||||
if !ok {
|
||||
return "", errors.New("token 载荷异常")
|
||||
}
|
||||
return claims.Subject, nil
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// Package idgen 全局雪花 ID 生成器,主键统一用字符串形式。
|
||||
package idgen
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
"github.com/bwmarrin/snowflake"
|
||||
)
|
||||
|
||||
var node *snowflake.Node
|
||||
|
||||
// Init 初始化雪花节点;nodeID 取 SUNDYNIX_NODE_ID,默认 1。
|
||||
func Init() error {
|
||||
nodeID := int64(1)
|
||||
if v := os.Getenv("SUNDYNIX_NODE_ID"); v != "" {
|
||||
n, err := strconv.ParseInt(v, 10, 64)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
nodeID = n
|
||||
}
|
||||
n, err := snowflake.NewNode(nodeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
node = n
|
||||
return nil
|
||||
}
|
||||
|
||||
// Next 生成一个字符串雪花 ID。
|
||||
func Next() string {
|
||||
return node.Generate().String()
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// Package req 公共请求参数。
|
||||
package req
|
||||
|
||||
// PageQuery 分页 + 关键词,所有列表接口通用。
|
||||
type PageQuery struct {
|
||||
Page int `form:"page,default=1"`
|
||||
PageSize int `form:"page_size,default=10"`
|
||||
Keyword string `form:"keyword"`
|
||||
}
|
||||
|
||||
// Normalize 约束分页边界。
|
||||
func (q *PageQuery) Normalize() {
|
||||
if q.Page < 1 {
|
||||
q.Page = 1
|
||||
}
|
||||
if q.PageSize < 1 {
|
||||
q.PageSize = 10
|
||||
}
|
||||
if q.PageSize > 100 {
|
||||
q.PageSize = 100
|
||||
}
|
||||
}
|
||||
|
||||
// Offset 计算偏移量。
|
||||
func (q *PageQuery) Offset() int {
|
||||
return (q.Page - 1) * q.PageSize
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
// Package resp 统一结果响应:{code, message, data}。
|
||||
// code = 0 表示成功,非 0 为业务错误码。
|
||||
package resp
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
CodeOK = 0
|
||||
CodeBadRequest = 40000
|
||||
CodeUnauthorized = 40100
|
||||
CodeNotFound = 40400
|
||||
CodeServerError = 50000
|
||||
)
|
||||
|
||||
type Body struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data any `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
// PageResult 分页数据统一结构。
|
||||
type PageResult struct {
|
||||
List any `json:"list"`
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
}
|
||||
|
||||
func OK(c *gin.Context, data any) {
|
||||
c.JSON(http.StatusOK, Body{Code: CodeOK, Message: "ok", Data: data})
|
||||
}
|
||||
|
||||
func Page(c *gin.Context, list any, total int64, page, pageSize int) {
|
||||
OK(c, PageResult{List: list, Total: total, Page: page, PageSize: pageSize})
|
||||
}
|
||||
|
||||
func BadRequest(c *gin.Context, message string) {
|
||||
c.JSON(http.StatusBadRequest, Body{Code: CodeBadRequest, Message: message})
|
||||
}
|
||||
|
||||
func Unauthorized(c *gin.Context, message string) {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, Body{Code: CodeUnauthorized, Message: message})
|
||||
}
|
||||
|
||||
func NotFound(c *gin.Context, message string) {
|
||||
c.JSON(http.StatusNotFound, Body{Code: CodeNotFound, Message: message})
|
||||
}
|
||||
|
||||
func ServerError(c *gin.Context, message string) {
|
||||
c.JSON(http.StatusInternalServerError, Body{Code: CodeServerError, Message: message})
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"git.sundynix.cn/Blizzard/sundynix-site/server/internal/handler"
|
||||
"git.sundynix.cn/Blizzard/sundynix-site/server/internal/middleware"
|
||||
"git.sundynix.cn/Blizzard/sundynix-site/server/internal/pkg/resp"
|
||||
"git.sundynix.cn/Blizzard/sundynix-site/server/internal/webfs"
|
||||
)
|
||||
|
||||
func New(db *gorm.DB) (*gin.Engine, error) {
|
||||
r := gin.New()
|
||||
r.Use(gin.Logger(), gin.Recovery(), middleware.CORS())
|
||||
|
||||
// ── 公开 API ──
|
||||
api := r.Group("/api")
|
||||
{
|
||||
api.GET("/healthz", func(c *gin.Context) {
|
||||
resp.OK(c, gin.H{"status": "ok"})
|
||||
})
|
||||
|
||||
posts := handler.NewPostHandler(db)
|
||||
api.GET("/posts", posts.List)
|
||||
api.GET("/posts/:slug", posts.Get)
|
||||
|
||||
api.GET("/releases/latest", handler.NewReleaseHandler().Latest)
|
||||
}
|
||||
|
||||
// RSS 订阅源(页脚 RSS 链接指向这里)
|
||||
r.GET("/rss.xml", handler.NewRSSHandler(db).Feed)
|
||||
|
||||
// ── 管理端 API ──
|
||||
adminAuth := handler.NewAdminAuthHandler()
|
||||
api.POST("/admin/login", adminAuth.Login)
|
||||
|
||||
adminAPI := api.Group("/admin", middleware.Auth())
|
||||
{
|
||||
posts := handler.NewAdminPostHandler(db)
|
||||
adminAPI.GET("/posts", posts.List)
|
||||
adminAPI.POST("/posts", posts.Create)
|
||||
adminAPI.GET("/posts/:id", posts.Get)
|
||||
adminAPI.PUT("/posts/:id", posts.Update)
|
||||
adminAPI.DELETE("/posts/:id", posts.Delete)
|
||||
}
|
||||
|
||||
// ── 静态站点:/admin → 管理端,其余 → 用户端 ──
|
||||
webDist, err := webfs.WebDist()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
adminDist, err := webfs.AdminDist()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
webServer := http.FileServer(http.FS(webDist))
|
||||
adminServer := http.StripPrefix("/admin", http.FileServer(http.FS(adminDist)))
|
||||
|
||||
r.NoRoute(func(c *gin.Context) {
|
||||
path := c.Request.URL.Path
|
||||
if strings.HasPrefix(path, "/api/") {
|
||||
resp.NotFound(c, "接口不存在")
|
||||
return
|
||||
}
|
||||
if path == "/admin" || strings.HasPrefix(path, "/admin/") {
|
||||
serveSPA(c, adminDist, adminServer, strings.TrimPrefix(path, "/admin"), "/admin/")
|
||||
return
|
||||
}
|
||||
serveSPA(c, webDist, webServer, path, "/")
|
||||
})
|
||||
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// serveSPA 真实文件直接吐,否则回退 index.html 交给前端路由。
|
||||
func serveSPA(c *gin.Context, dist fs.FS, server http.Handler, rel, fallback string) {
|
||||
p := strings.TrimPrefix(rel, "/")
|
||||
if p != "" {
|
||||
if _, err := fs.Stat(dist, p); err == nil {
|
||||
server.ServeHTTP(c.Writer, c.Request)
|
||||
return
|
||||
}
|
||||
}
|
||||
c.Request.URL.Path = fallback
|
||||
server.ServeHTTP(c.Writer, c.Request)
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
// Package service 业务服务层。
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Release 对外暴露的最新版本信息。
|
||||
type Release struct {
|
||||
Version string `json:"version"`
|
||||
PageURL string `json:"page_url"`
|
||||
MacOS string `json:"macos_url,omitempty"`
|
||||
Windows string `json:"windows_url,omitempty"`
|
||||
Source string `json:"source"` // gitea | fallback
|
||||
}
|
||||
|
||||
// ReleaseService 代理 gitea Releases API,带缓存与静态回退。
|
||||
type ReleaseService struct {
|
||||
giteaBase string
|
||||
repo string
|
||||
fallback Release
|
||||
|
||||
mu sync.Mutex
|
||||
cached *Release
|
||||
cachedAt time.Time
|
||||
ttl time.Duration
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func NewReleaseService() *ReleaseService {
|
||||
base := envOr("SUNDYNIX_GITEA_BASE", "https://git.sundynix.cn")
|
||||
repo := envOr("SUNDYNIX_RELEASE_REPO", "sundynix/sundynix-agentix")
|
||||
pageURL := fmt.Sprintf("%s/%s/releases", base, repo)
|
||||
return &ReleaseService{
|
||||
giteaBase: base,
|
||||
repo: repo,
|
||||
fallback: Release{
|
||||
Version: envOr("SUNDYNIX_FALLBACK_VERSION", "v0.1.2"),
|
||||
PageURL: pageURL,
|
||||
Source: "fallback",
|
||||
},
|
||||
ttl: 10 * time.Minute,
|
||||
client: &http.Client{Timeout: 8 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// Latest 返回最新 release;gitea 不可达或无发布时回退静态配置。
|
||||
func (s *ReleaseService) Latest() Release {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.cached != nil && time.Since(s.cachedAt) < s.ttl {
|
||||
return *s.cached
|
||||
}
|
||||
rel := s.fetch()
|
||||
s.cached = &rel
|
||||
s.cachedAt = time.Now()
|
||||
return rel
|
||||
}
|
||||
|
||||
type giteaAsset struct {
|
||||
Name string `json:"name"`
|
||||
URL string `json:"browser_download_url"`
|
||||
}
|
||||
|
||||
type giteaRelease struct {
|
||||
TagName string `json:"tag_name"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
Draft bool `json:"draft"`
|
||||
Prerelease bool `json:"prerelease"`
|
||||
Assets []giteaAsset `json:"assets"`
|
||||
}
|
||||
|
||||
func (s *ReleaseService) fetch() Release {
|
||||
url := fmt.Sprintf("%s/api/v1/repos/%s/releases?limit=5", s.giteaBase, s.repo)
|
||||
resp, err := s.client.Get(url)
|
||||
if err != nil {
|
||||
return s.fallback
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return s.fallback
|
||||
}
|
||||
|
||||
var list []giteaRelease
|
||||
if err := json.NewDecoder(resp.Body).Decode(&list); err != nil {
|
||||
return s.fallback
|
||||
}
|
||||
for _, r := range list {
|
||||
if r.Draft || r.Prerelease {
|
||||
continue
|
||||
}
|
||||
rel := Release{
|
||||
Version: r.TagName,
|
||||
PageURL: r.HTMLURL,
|
||||
Source: "gitea",
|
||||
}
|
||||
if rel.PageURL == "" {
|
||||
rel.PageURL = s.fallback.PageURL
|
||||
}
|
||||
for _, a := range r.Assets {
|
||||
name := strings.ToLower(a.Name)
|
||||
switch {
|
||||
case strings.HasSuffix(name, ".dmg"),
|
||||
strings.Contains(name, "darwin"),
|
||||
strings.Contains(name, "macos"):
|
||||
rel.MacOS = a.URL
|
||||
case strings.HasSuffix(name, ".exe"),
|
||||
strings.Contains(name, "windows"):
|
||||
rel.Windows = a.URL
|
||||
}
|
||||
}
|
||||
return rel
|
||||
}
|
||||
return s.fallback
|
||||
}
|
||||
|
||||
func envOr(key, fallback string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
"gorm.io/gorm/schema"
|
||||
|
||||
"git.sundynix.cn/Blizzard/sundynix-site/server/internal/model"
|
||||
)
|
||||
|
||||
// Open 打开数据库并完成迁移与种子数据。
|
||||
// DSN 含 "@tcp(" 走 MySQL,否则按 SQLite 文件路径处理(本地开发兜底)。
|
||||
// 命名规范:表前缀 sundynix_、单数表名、snake_case 列名。
|
||||
func Open(dsn string) (*gorm.DB, error) {
|
||||
var dialector gorm.Dialector
|
||||
if strings.Contains(dsn, "@tcp(") {
|
||||
dialector = mysql.Open(dsn)
|
||||
} else {
|
||||
dialector = sqlite.Open(dsn)
|
||||
}
|
||||
db, err := gorm.Open(dialector, &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Warn),
|
||||
TranslateError: true, // 唯一索引冲突 → gorm.ErrDuplicatedKey
|
||||
NamingStrategy: schema.NamingStrategy{
|
||||
TablePrefix: "sundynix_",
|
||||
SingularTable: true,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := db.AutoMigrate(&model.Post{}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := seed(db); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// seed 空库时写入示例文章,方便前端联调。
|
||||
func seed(db *gorm.DB) error {
|
||||
var count int64
|
||||
if err := db.Model(&model.Post{}).Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count > 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
at := func(s string) *time.Time {
|
||||
t, _ := time.Parse("2006-01-02", s)
|
||||
return &t
|
||||
}
|
||||
|
||||
posts := []model.Post{
|
||||
{
|
||||
Slug: "v0-1-2-release",
|
||||
Title: "sundynix agentix v0.1.2 发布:应用内更新与团队视图",
|
||||
Category: "RELEASE",
|
||||
Summary: "v0.1.2 带来应用内更新横幅、卡通办公室团队视图,以及发布流水线自动出包。",
|
||||
PublishedAt: at("2026-07-02"),
|
||||
Content: `## 亮点
|
||||
|
||||
- **应用内更新**:新版本发布后,工作台顶部出现更新横幅,一键升级。
|
||||
- **团队视图**:多智能体协作时,AI 角色会在卡通办公室里走动干活。
|
||||
- **发布流水线**:GitHub Actions 自动产出 macOS universal .app 与 Windows .exe。
|
||||
|
||||
## 升级方式
|
||||
|
||||
桌面版直接点更新横幅;自托管用户:
|
||||
|
||||
` + "```bash\ndocker compose pull && docker compose up -d\n```" + `
|
||||
|
||||
完整变更见 Releases 页面。`,
|
||||
},
|
||||
{
|
||||
Slug: "hybrid-retrieval",
|
||||
Title: "三路混合检索是怎么工作的:vector + fulltext + graph",
|
||||
Category: "ENGINEERING",
|
||||
Summary: "向量召回语义、全文召回关键词、图谱召回关系,RRF 把三路结果融成一路。",
|
||||
PublishedAt: at("2026-06-18"),
|
||||
Content: `## 为什么一路不够
|
||||
|
||||
单靠向量检索,专有名词和精确匹配经常翻车;单靠全文检索,又抓不到语义近邻。
|
||||
|
||||
我们的做法是三路并发:
|
||||
|
||||
| 通路 | 引擎 | 擅长 |
|
||||
|------|------|------|
|
||||
| 向量 | Milvus | 语义相似 |
|
||||
| 全文 | Bleve | 关键词精确匹配 |
|
||||
| 图谱 | Neo4j | 实体关系跳跃 |
|
||||
|
||||
三路结果用 **RRF(Reciprocal Rank Fusion)** 融合,再过一遍 rerank。
|
||||
|
||||
## 调试
|
||||
|
||||
检索控制台会展示每一路的召回与得分,坏 case 一眼定位。`,
|
||||
},
|
||||
{
|
||||
Slug: "why-event-driven",
|
||||
Title: "为什么我们选择事件驱动:NATS 零拷贝骨干网设计记",
|
||||
Category: "ARCHITECTURE",
|
||||
Summary: "Agent 的一切都是流:token、执行轨迹、工具调用。事件总线是最自然的骨架。",
|
||||
PublishedAt: at("2026-05-30"),
|
||||
Content: `## 流式优先
|
||||
|
||||
LLM 的输出天生是 token 流,Agent 的执行天生是事件序列。与其在 HTTP 请求-响应模型上硬凑,不如让整个系统跑在消息总线上。
|
||||
|
||||
## 主题设计
|
||||
|
||||
` + "```\nsundynix.tasks.* # 任务派发\nsundynix.streams.<id> # token 流\nsundynix.tools.go.* # Go 工具调用\nsundynix.tools.py.* # Python 工具调用\n```" + `
|
||||
|
||||
网关订阅流主题直接转 SSE/WS,中间零拷贝。
|
||||
|
||||
## 演进
|
||||
|
||||
单体先行(Monolith First):现在所有模块跑在一个进程里,但彼此只通过总线说话——拆微服务时只需要把订阅者搬走(Morph B)。`,
|
||||
},
|
||||
}
|
||||
return db.Create(&posts).Error
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/admin/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="robots" content="noindex" />
|
||||
<title>sundynix admin — 内容管理</title>
|
||||
<script>
|
||||
// 避免主题闪烁:React 挂载前先落 dark class
|
||||
;(function () {
|
||||
var t = localStorage.getItem('sundynix-theme')
|
||||
var dark =
|
||||
t === 'dark' ||
|
||||
((t === null || t === 'system') &&
|
||||
window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||
if (dark) document.documentElement.classList.add('dark')
|
||||
})()
|
||||
</script>
|
||||
<script type="module" crossorigin src="/admin/assets/index-B2HotWD6.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/admin/assets/index-BFElCR1f.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,29 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta
|
||||
name="description"
|
||||
content="sundynix agentix — 事件驱动的 AI Agent 工作台。画布编排智能体,多 Agent 团队研究、检索、生成真正的 Word 报告。"
|
||||
/>
|
||||
<title>sundynix agentix — 事件驱动的 AI Agent 工作台</title>
|
||||
<script>
|
||||
// 避免主题闪烁:React 挂载前先落 dark class
|
||||
;(function () {
|
||||
var t = localStorage.getItem('sundynix-theme')
|
||||
var dark =
|
||||
t === 'dark' ||
|
||||
((t === null || t === 'system') &&
|
||||
window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||
if (dark) document.documentElement.classList.add('dark')
|
||||
})()
|
||||
</script>
|
||||
<script type="module" crossorigin src="/assets/index-BpINEBaL.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-nl4Y73rF.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,25 @@
|
||||
// Package webfs 把前端构建产物打进二进制。
|
||||
// dist/(用户端)与 admin_dist/(管理端)默认只有占位页;
|
||||
// make build 会先构建 web/ 和 admin/ 并拷贝产物到这里,再编译 Go。
|
||||
package webfs
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
)
|
||||
|
||||
//go:embed all:dist
|
||||
var embeddedWeb embed.FS
|
||||
|
||||
//go:embed all:admin_dist
|
||||
var embeddedAdmin embed.FS
|
||||
|
||||
// WebDist 用户端静态文件系统(挂 /)。
|
||||
func WebDist() (fs.FS, error) {
|
||||
return fs.Sub(embeddedWeb, "dist")
|
||||
}
|
||||
|
||||
// AdminDist 管理端静态文件系统(挂 /admin)。
|
||||
func AdminDist() (fs.FS, error) {
|
||||
return fs.Sub(embeddedAdmin, "admin_dist")
|
||||
}
|
||||
@@ -2,12 +2,21 @@
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="icon" type="image/png" sizes="64x64" href="/favicon.png" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta
|
||||
name="description"
|
||||
content="sundynix agentix — 事件驱动的 AI Agent 工作台。画布编排智能体,多 Agent 团队研究、检索、生成真正的 Word 报告。"
|
||||
/>
|
||||
<meta property="og:title" content="sundynix agentix — 事件驱动的 AI Agent 工作台" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="画布编排智能体,多 Agent 团队研究、检索、生成真正的 Word 报告。"
|
||||
/>
|
||||
<meta property="og:image" content="https://sundynix.com/brand/logo-full.jpg" />
|
||||
<meta property="og:type" content="website" />
|
||||
<title>sundynix agentix — 事件驱动的 AI Agent 工作台</title>
|
||||
<script>
|
||||
// 避免主题闪烁:React 挂载前先落 dark class
|
||||
|
||||
@@ -10,13 +10,16 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/typography": "^0.5.20",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^1.24.0",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^7.18.1",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tailwindcss": "^4.3.3"
|
||||
},
|
||||
|
||||
|
After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 308 KiB |
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 10 KiB |
@@ -1,5 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" rx="7" fill="#10201b"/>
|
||||
<path d="M9 20.5 15 10l3.2 5.6L21 11l2.5 4.3" fill="none" stroke="#3ecdad" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<circle cx="23.5" cy="15.3" r="1.6" fill="#3ecdad"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 323 B |
@@ -2,6 +2,7 @@ import { Route, Routes } from 'react-router-dom'
|
||||
import { SiteLayout } from '@/components/layout/site-layout'
|
||||
import HomePage from '@/pages/home'
|
||||
import BlogPage from '@/pages/blog'
|
||||
import BlogPostPage from '@/pages/blog-post'
|
||||
import DownloadPage from '@/pages/download'
|
||||
import NotFoundPage from '@/pages/not-found'
|
||||
|
||||
@@ -11,6 +12,7 @@ export default function App() {
|
||||
<Route element={<SiteLayout />}>
|
||||
<Route index element={<HomePage />} />
|
||||
<Route path="blog" element={<BlogPage />} />
|
||||
<Route path="blog/:slug" element={<BlogPostPage />} />
|
||||
<Route path="download" element={<DownloadPage />} />
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Route>
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
/** 博客 API 封装 — dev 下由 vite 代理到 :8090,生产同源。
|
||||
* 后端统一响应信封:{ code, message, data },code=0 为成功。 */
|
||||
|
||||
export interface PostListItem {
|
||||
id: string
|
||||
slug: string
|
||||
title: string
|
||||
category: string
|
||||
summary: string
|
||||
published_at: string | null
|
||||
}
|
||||
|
||||
export interface Post extends PostListItem {
|
||||
content: string
|
||||
}
|
||||
|
||||
interface Envelope<T> {
|
||||
code: number
|
||||
message: string
|
||||
data: T
|
||||
}
|
||||
|
||||
class ApiError extends Error {
|
||||
status: number
|
||||
code: number
|
||||
|
||||
constructor(status: number, code: number, message: string) {
|
||||
super(message)
|
||||
this.status = status
|
||||
this.code = code
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T>(path: string): Promise<T> {
|
||||
const res = await fetch(path)
|
||||
let body: Envelope<T> | null = null
|
||||
try {
|
||||
body = (await res.json()) as Envelope<T>
|
||||
} catch {
|
||||
// 非 JSON 响应
|
||||
}
|
||||
if (!res.ok || !body || body.code !== 0) {
|
||||
throw new ApiError(
|
||||
res.status,
|
||||
body?.code ?? -1,
|
||||
body?.message ?? `请求失败(${res.status})`,
|
||||
)
|
||||
}
|
||||
return body.data
|
||||
}
|
||||
|
||||
export function fetchPosts(): Promise<PostListItem[]> {
|
||||
return request<PostListItem[]>('/api/posts')
|
||||
}
|
||||
|
||||
export function fetchPost(slug: string): Promise<Post> {
|
||||
return request<Post>(`/api/posts/${encodeURIComponent(slug)}`)
|
||||
}
|
||||
|
||||
export function isNotFound(err: unknown): boolean {
|
||||
return err instanceof ApiError && err.status === 404
|
||||
}
|
||||
|
||||
export function formatDate(iso: string | null): string {
|
||||
if (!iso) return ''
|
||||
return iso.slice(0, 10)
|
||||
}
|
||||
@@ -1,26 +1,85 @@
|
||||
const FOOTER_LINKS = [
|
||||
{ label: 'GITHUB', href: 'https://github.com/sundynix' },
|
||||
{ label: 'DOCS', href: '/docs' },
|
||||
{ label: 'RELEASES', href: '/download' },
|
||||
{ label: 'RSS', href: '/rss.xml' },
|
||||
]
|
||||
import { Link } from 'react-router-dom'
|
||||
import { SITE } from '@/content/site'
|
||||
import { LogoMark } from '@/components/logo'
|
||||
|
||||
const COLUMNS = [
|
||||
{
|
||||
title: 'PRODUCT',
|
||||
links: [
|
||||
{ label: '功能', href: '/#features' },
|
||||
{ label: '架构', href: '/#architecture' },
|
||||
{ label: '下载', href: '/download', internal: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'RESOURCES',
|
||||
links: [
|
||||
{ label: '博客', href: '/blog', internal: true },
|
||||
{ label: '源码仓库', href: SITE.repo },
|
||||
{ label: 'RSS 订阅', href: '/rss.xml' },
|
||||
],
|
||||
},
|
||||
] as const
|
||||
|
||||
export function SiteFooter() {
|
||||
return (
|
||||
<footer className="border-t border-hairline">
|
||||
<div className="mx-auto flex max-w-6xl flex-wrap items-center justify-between gap-2 px-6 py-6 font-mono text-[12.5px] text-ink-3 lg:px-10">
|
||||
<span>© 2026 SUNDYNIX AGENTIX</span>
|
||||
<span className="flex gap-4">
|
||||
{FOOTER_LINKS.map((l) => (
|
||||
<div className="mx-auto grid max-w-6xl gap-10 px-6 py-12 md:grid-cols-[1.5fr_1fr_1fr] lg:px-10">
|
||||
<div>
|
||||
<p className="flex items-center gap-2.5 font-mono text-[15px] font-semibold">
|
||||
<LogoMark size={30} />
|
||||
sundynix <em className="not-italic text-accent">agentix</em>
|
||||
</p>
|
||||
<p className="mt-2 text-[14px] text-ink-2">{SITE.tagline}</p>
|
||||
<p className="mt-4 font-mono text-[11.5px] tracking-[0.08em] text-ink-3">
|
||||
{SITE.version} · MACOS / WINDOWS / SELF-HOSTED
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{COLUMNS.map((col) => (
|
||||
<nav key={col.title} aria-label={col.title}>
|
||||
<p className="mb-3.5 font-mono text-[11px] tracking-[0.16em] text-ink-3">
|
||||
{col.title}
|
||||
</p>
|
||||
<ul className="space-y-2.5">
|
||||
{col.links.map((link) => (
|
||||
<li key={link.label}>
|
||||
{'internal' in link && link.internal ? (
|
||||
<Link
|
||||
to={link.href}
|
||||
className="text-[13.5px] text-ink-2 transition-colors hover:text-accent-ink"
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
) : (
|
||||
<a
|
||||
key={l.label}
|
||||
href={l.href}
|
||||
href={link.href}
|
||||
target={link.href.startsWith('http') ? '_blank' : undefined}
|
||||
rel={link.href.startsWith('http') ? 'noreferrer' : undefined}
|
||||
className="text-[13.5px] text-ink-2 transition-colors hover:text-accent-ink"
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-hairline">
|
||||
<div className="mx-auto flex max-w-6xl flex-wrap items-center justify-between gap-x-6 gap-y-2 px-6 py-5 font-mono text-[12px] text-ink-3 lg:px-10">
|
||||
<span>© 2026 SUNDYNIX AGENTIX</span>
|
||||
<a
|
||||
href="https://beian.miit.gov.cn/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="transition-colors hover:text-accent-ink"
|
||||
>
|
||||
{l.label}
|
||||
{SITE.icp}
|
||||
</a>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState } from 'react'
|
||||
import { Link, NavLink } from 'react-router-dom'
|
||||
import { Menu, Moon, Sun, X } from 'lucide-react'
|
||||
import { useTheme } from '@/components/theme-provider'
|
||||
import { LogoMark } from '@/components/logo'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const NAV_ITEMS = [
|
||||
@@ -63,7 +64,8 @@ export function SiteHeader() {
|
||||
return (
|
||||
<header className="sticky top-0 z-40 border-b border-hairline bg-ground/85 backdrop-blur">
|
||||
<div className="mx-auto flex h-16 max-w-6xl items-center justify-between px-6 lg:px-10">
|
||||
<Link to="/" className="font-mono text-[15px] font-semibold">
|
||||
<Link to="/" className="flex items-center gap-2.5 font-mono text-[15px] font-semibold">
|
||||
<LogoMark size={26} />
|
||||
sundynix <em className="not-italic text-accent">agentix</em>
|
||||
</Link>
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
interface LogoMarkProps {
|
||||
size?: number
|
||||
className?: string
|
||||
}
|
||||
|
||||
/** 品牌 Logo:霓虹数据流 S(用户原版位图裁切,源图在 design-assets/) */
|
||||
export function LogoMark({ size = 28, className }: LogoMarkProps) {
|
||||
return (
|
||||
<img
|
||||
src="/brand/logo-mark.webp"
|
||||
width={size}
|
||||
height={size}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className={className}
|
||||
style={{ borderRadius: '22%' }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface RevealProps {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
/** 进场延迟(毫秒),用于同屏元素错峰 */
|
||||
delay?: number
|
||||
}
|
||||
|
||||
/** 滚动进场:进入视口后淡入上移一次;prefers-reduced-motion 时直接显示 */
|
||||
export function Reveal({ children, className, delay = 0 }: RevealProps) {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const [visible, setVisible] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current
|
||||
if (!el) return
|
||||
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||
setVisible(true)
|
||||
return
|
||||
}
|
||||
const io = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
setVisible(true)
|
||||
io.disconnect()
|
||||
}
|
||||
},
|
||||
{ threshold: 0.15, rootMargin: '0px 0px -40px 0px' },
|
||||
)
|
||||
io.observe(el)
|
||||
return () => io.disconnect()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
style={delay ? { transitionDelay: `${delay}ms` } : undefined}
|
||||
className={cn('reveal', visible && 'reveal-in', className)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -13,21 +13,25 @@ export function TerminalDemo() {
|
||||
</span>
|
||||
</div>
|
||||
<div className="overflow-x-auto px-5 pb-6 pt-5 font-mono text-[13.5px] leading-[2.05] text-term-ink">
|
||||
<div>
|
||||
<span className="font-semibold text-[#3ecdad]">› </span>
|
||||
<div className="term-line">
|
||||
<span className="font-semibold text-[#22d3ee]">› </span>
|
||||
{TERMINAL_DEMO.prompt}
|
||||
</div>
|
||||
<div className="text-white/35">
|
||||
<div className="term-line text-white/35" style={{ animationDelay: '0.35s' }}>
|
||||
──────────────────────────────────
|
||||
</div>
|
||||
{TERMINAL_DEMO.events.map((ev, i) => (
|
||||
<div key={ev.tag} className="whitespace-nowrap">
|
||||
<span className={ev.ok ? 'text-[#a6d96a]' : 'text-[#7fb8e8]'}>
|
||||
<div
|
||||
key={ev.tag}
|
||||
className="term-line whitespace-nowrap"
|
||||
style={{ animationDelay: `${0.7 + i * 0.45}s` }}
|
||||
>
|
||||
<span className={ev.ok ? 'text-[#a6d96a]' : 'text-[#60a5fa]'}>
|
||||
[{ev.tag}]
|
||||
</span>{' '}
|
||||
{ev.text}
|
||||
{i === TERMINAL_DEMO.events.length - 1 && (
|
||||
<span className="caret-blink ml-1 inline-block h-[15px] w-2 translate-y-0.5 bg-[#3ecdad]" />
|
||||
<span className="caret-blink ml-1 inline-block h-[15px] w-2 translate-y-0.5 bg-[#22d3ee]" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
/** 站点文案与数据,改这里即可,不用动组件 */
|
||||
|
||||
export const SITE = {
|
||||
/** 静态回退版本号;线上以 /api/releases/latest 为准 */
|
||||
version: 'v0.1.2',
|
||||
tagline: '事件驱动的 AI Agent 工作台',
|
||||
github: 'https://github.com/sundynix/agentix',
|
||||
repo: 'https://git.sundynix.cn/sundynix/sundynix-agentix',
|
||||
icp: '滇ICP备2025056308号-1',
|
||||
}
|
||||
|
||||
export const TERMINAL_DEMO = {
|
||||
@@ -117,21 +119,21 @@ export const DOWNLOADS = [
|
||||
os: 'macOS',
|
||||
meta: 'universal · .app · v0.1.2',
|
||||
action: '下载 .dmg',
|
||||
href: '#',
|
||||
href: `${SITE.repo}/releases`,
|
||||
highlight: true,
|
||||
},
|
||||
{
|
||||
os: 'Windows',
|
||||
meta: 'x64 · .exe · v0.1.2',
|
||||
action: '下载 .exe',
|
||||
href: '#',
|
||||
href: `${SITE.repo}/releases`,
|
||||
highlight: false,
|
||||
},
|
||||
{
|
||||
os: '自托管',
|
||||
meta: 'docker compose · 全平台',
|
||||
action: '部署文档',
|
||||
href: '#',
|
||||
href: SITE.repo,
|
||||
highlight: false,
|
||||
},
|
||||
]
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { useEffect } from 'react'
|
||||
|
||||
const BASE = 'sundynix agentix'
|
||||
const DEFAULT = `${BASE} — 事件驱动的 AI Agent 工作台`
|
||||
|
||||
/** 设置页面标题;不传参恢复默认 */
|
||||
export function usePageTitle(title?: string) {
|
||||
useEffect(() => {
|
||||
document.title = title ? `${title} — ${BASE}` : DEFAULT
|
||||
return () => {
|
||||
document.title = DEFAULT
|
||||
}
|
||||
}, [title])
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { SITE } from '@/content/site'
|
||||
|
||||
export interface ReleaseInfo {
|
||||
version: string
|
||||
page_url: string
|
||||
macos_url?: string
|
||||
windows_url?: string
|
||||
source: 'gitea' | 'fallback'
|
||||
}
|
||||
|
||||
const STATIC_FALLBACK: ReleaseInfo = {
|
||||
version: SITE.version,
|
||||
page_url: `${SITE.repo}/releases`,
|
||||
source: 'fallback',
|
||||
}
|
||||
|
||||
// 模块级缓存:整个会话只请求一次,多处组件共享
|
||||
let cache: ReleaseInfo | null = null
|
||||
let inflight: Promise<ReleaseInfo> | null = null
|
||||
|
||||
function load(): Promise<ReleaseInfo> {
|
||||
if (cache) return Promise.resolve(cache)
|
||||
inflight ??= fetch('/api/releases/latest')
|
||||
.then(async (res) => {
|
||||
const body = (await res.json()) as { code: number; data: ReleaseInfo }
|
||||
if (!res.ok || body.code !== 0) throw new Error('release api failed')
|
||||
cache = body.data
|
||||
return cache
|
||||
})
|
||||
.catch(() => {
|
||||
cache = STATIC_FALLBACK
|
||||
return cache
|
||||
})
|
||||
return inflight
|
||||
}
|
||||
|
||||
/** 最新版本信息;请求完成前先给静态回退值,避免闪空 */
|
||||
export function useRelease(): ReleaseInfo {
|
||||
const [release, setRelease] = useState<ReleaseInfo>(cache ?? STATIC_FALLBACK)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
load().then((r) => {
|
||||
if (!cancelled) setRelease(r)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
return release
|
||||
}
|
||||
@@ -1,40 +1,48 @@
|
||||
@import "tailwindcss";
|
||||
@plugin "@tailwindcss/typography";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
/* ── 设计 token(设计稿 v0.2)────────────────────────────
|
||||
青瓷绿单强调色;中性色整体向青绿偏移;
|
||||
终端窗双主题共用深色。 */
|
||||
/* ── 设计 token(品牌改版 v2:跟随 logo)────────────────
|
||||
霓虹数据流 logo:青 #34E5E0 → 蓝 #3B82F6 → 紫 #A855F7;
|
||||
强调色取青蓝段,紫色只在 logo 与品牌渐变里出现;
|
||||
中性色向冷蓝偏移,终端窗为深岩蓝底。 */
|
||||
:root {
|
||||
--ground: #f6f8f7;
|
||||
--ground: #f5f7f9;
|
||||
--surface: #ffffff;
|
||||
--ink: #16211d;
|
||||
--ink-2: #55665f;
|
||||
--ink-3: #8a9891;
|
||||
--hairline: #e1e8e4;
|
||||
--hairline-strong: #c9d4cf;
|
||||
--accent: #0e7c6b;
|
||||
--accent-ink: #0a5a4e;
|
||||
--accent-soft: #e3f1ec;
|
||||
--code-bg: #edf2f0;
|
||||
--term-bg: #10201b;
|
||||
--term-ink: #c7e8de;
|
||||
--ink: #151c24;
|
||||
--ink-2: #526069;
|
||||
--ink-3: #8595a0;
|
||||
--hairline: #e0e7ec;
|
||||
--hairline-strong: #c6d2da;
|
||||
--accent: #0284c7;
|
||||
--accent-ink: #075985;
|
||||
--accent-soft: #e0f2fe;
|
||||
--code-bg: #ebf1f5;
|
||||
--term-bg: #0e1620;
|
||||
--term-ink: #c9e4f5;
|
||||
--brand-g1: #0891b2;
|
||||
--brand-g2: #2563eb;
|
||||
--brand-g3: #9333ea;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--ground: #0d1311;
|
||||
--surface: #131b18;
|
||||
--ink: #e7efeb;
|
||||
--ink-2: #93a69f;
|
||||
--ink-3: #64756e;
|
||||
--hairline: #22302b;
|
||||
--hairline-strong: #35453f;
|
||||
--accent: #3ecdad;
|
||||
--accent-ink: #6fdfc6;
|
||||
--accent-soft: #12352d;
|
||||
--code-bg: #182420;
|
||||
--term-bg: #0a1512;
|
||||
--term-ink: #a9d8cb;
|
||||
--ground: #0b1117;
|
||||
--surface: #111925;
|
||||
--ink: #e6edf3;
|
||||
--ink-2: #93a6b4;
|
||||
--ink-3: #62737f;
|
||||
--hairline: #1f2c38;
|
||||
--hairline-strong: #32434f;
|
||||
--accent: #38bdf8;
|
||||
--accent-ink: #7dd3fc;
|
||||
--accent-soft: #0b3049;
|
||||
--code-bg: #14202b;
|
||||
--term-bg: #0a121b;
|
||||
--term-ink: #bfe3f7;
|
||||
--brand-g1: #22d3ee;
|
||||
--brand-g2: #60a5fa;
|
||||
--brand-g3: #c084fc;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
@@ -74,6 +82,48 @@ body {
|
||||
color: var(--accent-ink);
|
||||
}
|
||||
|
||||
/* 品牌渐变文字(logo 同源:青→蓝→紫),克制使用 */
|
||||
.text-brand-gradient {
|
||||
background: linear-gradient(
|
||||
92deg,
|
||||
var(--brand-g1),
|
||||
var(--brand-g2) 55%,
|
||||
var(--brand-g3)
|
||||
);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
/* 文章排版 — prose 对齐设计 token */
|
||||
.prose-site {
|
||||
--tw-prose-body: var(--ink-2);
|
||||
--tw-prose-headings: var(--ink);
|
||||
--tw-prose-links: var(--accent-ink);
|
||||
--tw-prose-bold: var(--ink);
|
||||
--tw-prose-counters: var(--ink-3);
|
||||
--tw-prose-bullets: var(--hairline-strong);
|
||||
--tw-prose-hr: var(--hairline);
|
||||
--tw-prose-quotes: var(--ink-2);
|
||||
--tw-prose-quote-borders: var(--accent);
|
||||
--tw-prose-code: var(--accent-ink);
|
||||
--tw-prose-pre-bg: var(--term-bg);
|
||||
--tw-prose-pre-code: var(--term-ink);
|
||||
--tw-prose-th-borders: var(--hairline-strong);
|
||||
--tw-prose-td-borders: var(--hairline);
|
||||
--tw-prose-captions: var(--ink-3);
|
||||
}
|
||||
.prose-site :where(code):not(:where(pre code))::before,
|
||||
.prose-site :where(code):not(:where(pre code))::after {
|
||||
content: none;
|
||||
}
|
||||
.prose-site :where(code):not(:where(pre code)) {
|
||||
background: var(--code-bg);
|
||||
border-radius: 4px;
|
||||
padding: 2px 6px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 终端光标 */
|
||||
@keyframes caret-blink {
|
||||
50% {
|
||||
@@ -83,8 +133,47 @@ body {
|
||||
.caret-blink {
|
||||
animation: caret-blink 1.1s steps(1) infinite;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.caret-blink {
|
||||
animation: none;
|
||||
|
||||
/* 滚动进场(配合 Reveal 组件) */
|
||||
.reveal {
|
||||
opacity: 0;
|
||||
transform: translateY(16px);
|
||||
transition:
|
||||
opacity 0.6s ease,
|
||||
transform 0.6s ease;
|
||||
}
|
||||
.reveal-in {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* 终端事件流:逐行浮现 */
|
||||
@keyframes term-line-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(6px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
.term-line {
|
||||
opacity: 0;
|
||||
animation: term-line-in 0.45s ease forwards;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.caret-blink,
|
||||
.term-line {
|
||||
animation: none;
|
||||
}
|
||||
.term-line {
|
||||
opacity: 1;
|
||||
}
|
||||
.reveal {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import Markdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import { ArrowLeft } from 'lucide-react'
|
||||
import { fetchPost, formatDate, isNotFound, type Post } from '@/api/posts'
|
||||
import NotFoundPage from '@/pages/not-found'
|
||||
import { usePageTitle } from '@/hooks/use-page-title'
|
||||
|
||||
type State =
|
||||
| { status: 'loading' }
|
||||
| { status: 'not-found' }
|
||||
| { status: 'error'; message: string }
|
||||
| { status: 'ready'; post: Post }
|
||||
|
||||
export default function BlogPostPage() {
|
||||
const { slug = '' } = useParams()
|
||||
const [state, setState] = useState<State>({ status: 'loading' })
|
||||
usePageTitle(state.status === 'ready' ? state.post.title : undefined)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setState({ status: 'loading' })
|
||||
fetchPost(slug)
|
||||
.then((post) => {
|
||||
if (!cancelled) setState({ status: 'ready', post })
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
if (cancelled) return
|
||||
setState(
|
||||
isNotFound(err)
|
||||
? { status: 'not-found' }
|
||||
: { status: 'error', message: err.message },
|
||||
)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [slug])
|
||||
|
||||
if (state.status === 'not-found') return <NotFoundPage />
|
||||
|
||||
return (
|
||||
<section className="px-6 py-16 lg:px-10">
|
||||
<div className="mx-auto max-w-[720px]">
|
||||
<Link
|
||||
to="/blog"
|
||||
className="mb-8 inline-flex items-center gap-1.5 font-mono text-[12.5px] tracking-[0.08em] text-ink-3 transition-colors hover:text-accent-ink"
|
||||
>
|
||||
<ArrowLeft className="size-3.5" /> BLOG
|
||||
</Link>
|
||||
|
||||
{state.status === 'loading' && (
|
||||
<div className="space-y-4">
|
||||
<div className="h-9 w-3/4 animate-pulse rounded-lg bg-code" />
|
||||
<div className="h-4 w-1/3 animate-pulse rounded bg-code" />
|
||||
<div className="mt-8 h-48 animate-pulse rounded-lg bg-code" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state.status === 'error' && (
|
||||
<div className="rounded-lg border border-hairline bg-surface px-5 py-4 text-[14.5px] text-ink-2">
|
||||
加载失败:{state.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state.status === 'ready' && (
|
||||
<article>
|
||||
<header className="mb-9 border-b border-hairline pb-8">
|
||||
<div className="mb-4 flex items-center gap-4 font-mono text-[12px] tracking-[0.08em] text-ink-3">
|
||||
<time className="tabular-nums">
|
||||
{formatDate(state.post.published_at)}
|
||||
</time>
|
||||
<span className="text-accent-ink">{state.post.category}</span>
|
||||
</div>
|
||||
<h1 className="text-balance text-[30px] font-[650] leading-[1.3] tracking-[-0.02em]">
|
||||
{state.post.title}
|
||||
</h1>
|
||||
{state.post.summary && (
|
||||
<p className="mt-4 text-[15.5px] text-ink-2">
|
||||
{state.post.summary}
|
||||
</p>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<div className="prose prose-site max-w-none prose-headings:tracking-[-0.01em] prose-pre:rounded-[10px]">
|
||||
<Markdown remarkPlugins={[remarkGfm]}>
|
||||
{state.post.content}
|
||||
</Markdown>
|
||||
</div>
|
||||
</article>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1,25 +1,32 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { fetchPosts, formatDate, type PostListItem } from '@/api/posts'
|
||||
import { SectionLabel } from '@/components/section-label'
|
||||
import { usePageTitle } from '@/hooks/use-page-title'
|
||||
|
||||
/** 占位数据 — 接入后端后替换为 /api/posts */
|
||||
const POSTS = [
|
||||
{
|
||||
date: '2026-07-02',
|
||||
title: 'sundynix agentix v0.1.2 发布:应用内更新与团队视图',
|
||||
category: 'RELEASE',
|
||||
},
|
||||
{
|
||||
date: '2026-06-18',
|
||||
title: '三路混合检索是怎么工作的:vector + fulltext + graph',
|
||||
category: 'ENGINEERING',
|
||||
},
|
||||
{
|
||||
date: '2026-05-30',
|
||||
title: '为什么我们选择事件驱动:NATS 零拷贝骨干网设计记',
|
||||
category: 'ARCHITECTURE',
|
||||
},
|
||||
]
|
||||
type State =
|
||||
| { status: 'loading' }
|
||||
| { status: 'error'; message: string }
|
||||
| { status: 'ready'; posts: PostListItem[] }
|
||||
|
||||
export default function BlogPage() {
|
||||
usePageTitle('博客')
|
||||
const [state, setState] = useState<State>({ status: 'loading' })
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
fetchPosts()
|
||||
.then((posts) => {
|
||||
if (!cancelled) setState({ status: 'ready', posts })
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
if (!cancelled) setState({ status: 'error', message: err.message })
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<section className="px-6 py-16 lg:px-10">
|
||||
<div className="mx-auto max-w-4xl">
|
||||
@@ -29,17 +36,39 @@ export default function BlogPage() {
|
||||
博客与更新日志
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{state.status === 'loading' && (
|
||||
<div className="space-y-4">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-12 animate-pulse rounded-lg bg-code"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state.status === 'error' && (
|
||||
<div className="rounded-lg border border-hairline bg-surface px-5 py-4 text-[14.5px] text-ink-2">
|
||||
加载失败:{state.message}。确认后端已启动(make dev-server)。
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state.status === 'ready' &&
|
||||
(state.posts.length === 0 ? (
|
||||
<p className="text-[14.5px] text-ink-2">还没有文章。</p>
|
||||
) : (
|
||||
<div>
|
||||
{POSTS.map((post) => (
|
||||
{state.posts.map((post) => (
|
||||
<article
|
||||
key={post.title}
|
||||
key={post.slug}
|
||||
className="grid grid-cols-1 items-baseline gap-1 border-b border-hairline py-4.5 sm:grid-cols-[110px_1fr_auto] sm:gap-6"
|
||||
>
|
||||
<time className="font-mono text-[12.5px] tabular-nums text-ink-3">
|
||||
{post.date}
|
||||
{formatDate(post.published_at)}
|
||||
</time>
|
||||
<h2 className="text-[15.5px] font-medium transition-colors hover:text-accent-ink">
|
||||
<a href="#">{post.title}</a>
|
||||
<Link to={`/blog/${post.slug}`}>{post.title}</Link>
|
||||
</h2>
|
||||
<span className="font-mono text-[11.5px] tracking-[0.08em] text-ink-3">
|
||||
{post.category}
|
||||
@@ -47,6 +76,7 @@ export default function BlogPage() {
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { SectionLabel } from '@/components/section-label'
|
||||
import { DownloadGrid } from '@/components/download-grid'
|
||||
import { usePageTitle } from '@/hooks/use-page-title'
|
||||
|
||||
export default function DownloadPage() {
|
||||
usePageTitle('下载')
|
||||
return (
|
||||
<section className="px-6 py-16 lg:px-10">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Fragment } from 'react'
|
||||
import { ARCH_LAYERS } from '@/content/site'
|
||||
import { SectionLabel } from '@/components/section-label'
|
||||
import { Reveal } from '@/components/reveal'
|
||||
|
||||
export function Architecture() {
|
||||
return (
|
||||
@@ -9,6 +10,7 @@ export function Architecture() {
|
||||
className="border-t border-hairline px-6 py-16 lg:px-10"
|
||||
>
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<Reveal>
|
||||
<div className="mx-auto mb-9 max-w-[560px] text-center">
|
||||
<SectionLabel>ARCHITECTURE</SectionLabel>
|
||||
<h2 className="text-balance text-[26px] font-[650] tracking-[-0.02em]">
|
||||
@@ -18,8 +20,9 @@ export function Architecture() {
|
||||
Monolith First,为微服务演进预留缝隙(Morph B)。
|
||||
</p>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
<div className="mx-auto max-w-[760px]">
|
||||
<Reveal delay={120} className="mx-auto max-w-[760px]">
|
||||
{ARCH_LAYERS.map((layer, i) => (
|
||||
<Fragment key={layer.id}>
|
||||
{/* NATS 总线插在 L2 和 L4 之间 */}
|
||||
@@ -44,7 +47,7 @@ export function Architecture() {
|
||||
</div>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { SectionLabel } from '@/components/section-label'
|
||||
import { DownloadGrid } from '@/components/download-grid'
|
||||
import { Reveal } from '@/components/reveal'
|
||||
|
||||
export function DownloadCta() {
|
||||
return (
|
||||
<section className="border-t border-hairline px-6 py-16 lg:px-10">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<Reveal>
|
||||
<div className="mx-auto mb-9 max-w-[560px] text-center">
|
||||
<SectionLabel>DOWNLOAD</SectionLabel>
|
||||
<h2 className="text-balance text-[26px] font-[650] tracking-[-0.02em]">
|
||||
@@ -14,7 +16,10 @@ export function DownloadCta() {
|
||||
内置应用内更新,发布流水线自动出包。
|
||||
</p>
|
||||
</div>
|
||||
</Reveal>
|
||||
<Reveal delay={120}>
|
||||
<DownloadGrid />
|
||||
</Reveal>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { FEATURES } from '@/content/site'
|
||||
import { SectionLabel } from '@/components/section-label'
|
||||
import { Reveal } from '@/components/reveal'
|
||||
|
||||
export function Features() {
|
||||
return (
|
||||
<section id="features" className="border-t border-hairline px-6 py-16 lg:px-10">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<Reveal>
|
||||
<div className="mx-auto mb-9 max-w-[560px] text-center">
|
||||
<SectionLabel>CAPABILITIES</SectionLabel>
|
||||
<h2 className="text-balance text-[26px] font-[650] tracking-[-0.02em]">
|
||||
@@ -14,14 +16,16 @@ export function Features() {
|
||||
从编排到交付,每个环节都是流式的、可观测的、有守卫的。
|
||||
</p>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={120}>
|
||||
<div className="grid grid-cols-1 gap-px overflow-hidden rounded-xl border border-hairline bg-hairline sm:grid-cols-2 lg:grid-cols-4">
|
||||
{FEATURES.map((f) => (
|
||||
<div
|
||||
key={f.label}
|
||||
className="bg-surface p-6 transition-colors hover:bg-ground"
|
||||
className="group bg-surface p-6 transition-colors hover:bg-ground"
|
||||
>
|
||||
<span className="mb-3 block font-mono text-[10.5px] tracking-[0.16em] text-accent-ink">
|
||||
<span className="mb-3 block font-mono text-[10.5px] tracking-[0.16em] text-accent-ink transition-colors group-hover:text-accent">
|
||||
{f.label}
|
||||
</span>
|
||||
<h3 className="mb-1.5 text-base font-semibold">{f.title}</h3>
|
||||
@@ -29,6 +33,7 @@ export function Features() {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
|
||||
@@ -2,27 +2,36 @@ import { Link } from 'react-router-dom'
|
||||
import { Download } from 'lucide-react'
|
||||
import { SITE } from '@/content/site'
|
||||
import { TerminalDemo } from '@/components/terminal-demo'
|
||||
import { Reveal } from '@/components/reveal'
|
||||
|
||||
export function Hero() {
|
||||
return (
|
||||
<section className="px-6 pt-21 text-center lg:px-10">
|
||||
<Reveal>
|
||||
<span className="mb-6.5 inline-flex items-center gap-2 rounded-full border border-hairline-strong px-3.5 py-1 font-mono text-xs tracking-[0.08em] text-accent-ink">
|
||||
<span className="size-[7px] rounded-full bg-accent" />
|
||||
{SITE.version} 已发布 · macOS / Windows
|
||||
</span>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={80}>
|
||||
<h1 className="mx-auto max-w-[17em] text-balance text-4xl font-[650] leading-[1.18] tracking-[-0.028em] md:text-[52px] lg:text-[58px]">
|
||||
事件驱动的
|
||||
<br />
|
||||
AI Agent 工作台<span className="text-ink-3">。</span>
|
||||
<span className="text-brand-gradient">AI Agent 工作台</span>
|
||||
<span className="text-ink-3">。</span>
|
||||
</h1>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={160}>
|
||||
<p className="mx-auto mt-5.5 max-w-[37em] text-[17px] text-ink-2">
|
||||
在画布上编排智能体,让多 Agent
|
||||
团队替你研究、检索、写出真正的 Word
|
||||
报告。桌面开箱即用,也可一条命令私有化部署。
|
||||
</p>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={240}>
|
||||
<div className="mt-9 flex flex-wrap justify-center gap-3">
|
||||
<Link
|
||||
to="/download"
|
||||
@@ -31,7 +40,7 @@ export function Hero() {
|
||||
下载桌面版 <Download className="size-4" />
|
||||
</Link>
|
||||
<a
|
||||
href={SITE.github}
|
||||
href={SITE.repo}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center rounded-lg border border-hairline-strong px-6 py-2.5 font-mono text-[14px] transition-colors hover:border-accent"
|
||||
@@ -39,8 +48,11 @@ export function Hero() {
|
||||
$ make demo
|
||||
</a>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={320}>
|
||||
<TerminalDemo />
|
||||
</Reveal>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,29 +1,35 @@
|
||||
import { QUICKSTART_STEPS } from '@/content/site'
|
||||
import { SectionLabel } from '@/components/section-label'
|
||||
import { Reveal } from '@/components/reveal'
|
||||
|
||||
export function Quickstart() {
|
||||
return (
|
||||
<section className="border-t border-hairline px-6 py-16 lg:px-10">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<Reveal>
|
||||
<div className="mb-9 max-w-[560px]">
|
||||
<SectionLabel>QUICKSTART</SectionLabel>
|
||||
<h2 className="text-[26px] font-[650] tracking-[-0.02em]">
|
||||
五分钟跑起来
|
||||
</h2>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
<div className="grid grid-cols-1 items-start gap-6 md:grid-cols-2">
|
||||
<Reveal
|
||||
delay={120}
|
||||
className="grid grid-cols-1 items-start gap-6 md:grid-cols-2"
|
||||
>
|
||||
<pre className="overflow-x-auto rounded-[10px] bg-term px-5.5 py-5 font-mono text-[13.5px] leading-8 text-term-ink">
|
||||
<code>
|
||||
<span className="text-white/35"># 内嵌 NATS,零配置体验</span>
|
||||
{'\n'}
|
||||
<span className="text-[#3ecdad]">$</span> git clone
|
||||
https://github.com/sundynix/agentix{'\n'}
|
||||
<span className="text-[#3ecdad]">$</span> make demo{'\n'}
|
||||
<span className="text-[#22d3ee]">$</span> git clone
|
||||
https://git.sundynix.cn/sundynix/sundynix-agentix{'\n'}
|
||||
<span className="text-[#22d3ee]">$</span> make demo{'\n'}
|
||||
{'\n'}
|
||||
<span className="text-white/35"># 或一条命令私有化部署</span>
|
||||
{'\n'}
|
||||
<span className="text-[#3ecdad]">$</span> docker compose up -d
|
||||
<span className="text-[#22d3ee]">$</span> docker compose up -d
|
||||
</code>
|
||||
</pre>
|
||||
|
||||
@@ -43,7 +49,7 @@ export function Quickstart() {
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { usePageTitle } from '@/hooks/use-page-title'
|
||||
|
||||
export default function NotFoundPage() {
|
||||
usePageTitle('页面不存在')
|
||||
return (
|
||||
<section className="flex flex-col items-center px-6 py-28 text-center">
|
||||
<p className="font-mono text-[13px] tracking-[0.18em] text-accent">
|
||||
|
||||
@@ -15,7 +15,8 @@ export default defineConfig({
|
||||
port: 5173,
|
||||
proxy: {
|
||||
// 联调 Go 后端时启用:/api → gin
|
||||
'/api': 'http://localhost:8080',
|
||||
'/api': 'http://localhost:8090',
|
||||
'/rss.xml': 'http://localhost:8090',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||