mirror of
https://github.com/iflytek/skillhub.git
synced 2026-10-06 02:48:28 +00:00
merge: bring feature/project-init into main
This commit is contained in:
commit
0fbed7d450
67 changed files with 2087 additions and 835 deletions
18
.env.release.example
Normal file
18
.env.release.example
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
# `edge` tracks the latest build from the default branch.
|
||||
# For deterministic environments, pin a release tag like `v0.1.0`.
|
||||
SKILLHUB_VERSION=edge
|
||||
SKILLHUB_SERVER_IMAGE=ghcr.io/iflytek/skillhub-server
|
||||
SKILLHUB_WEB_IMAGE=ghcr.io/iflytek/skillhub-web
|
||||
|
||||
POSTGRES_PORT=5432
|
||||
POSTGRES_DB=skillhub
|
||||
POSTGRES_USER=skillhub
|
||||
POSTGRES_PASSWORD=skillhub_demo
|
||||
|
||||
REDIS_PORT=6379
|
||||
API_PORT=8080
|
||||
WEB_PORT=80
|
||||
|
||||
# Optional: configure real GitHub OAuth before exposing the stack to other users.
|
||||
OAUTH2_GITHUB_CLIENT_ID=local-placeholder
|
||||
OAUTH2_GITHUB_CLIENT_SECRET=local-placeholder
|
||||
81
.github/workflows/publish-images.yml
vendored
Normal file
81
.github/workflows/publish-images.yml
vendored
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
name: Publish Images
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- feature/project-init
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: publish-images-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
env:
|
||||
DOCKER_PLATFORMS: linux/amd64,linux/arm64
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: server
|
||||
context: ./server
|
||||
dockerfile: ./server/Dockerfile
|
||||
image: ghcr.io/${{ github.repository_owner }}/skillhub-server
|
||||
- name: web
|
||||
context: ./web
|
||||
dockerfile: ./web/Dockerfile
|
||||
image: ghcr.io/${{ github.repository_owner }}/skillhub-web
|
||||
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract image metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ matrix.image }}
|
||||
tags: |
|
||||
type=raw,value=edge,enable={{is_default_branch}}
|
||||
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||
type=ref,event=tag
|
||||
type=sha,format=short,prefix=sha-
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
|
||||
- name: Build and push ${{ matrix.name }}
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ${{ matrix.context }}
|
||||
file: ${{ matrix.dockerfile }}
|
||||
platforms: ${{ env.DOCKER_PLATFORMS }}
|
||||
push: true
|
||||
provenance: false
|
||||
sbom: false
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha,scope=${{ matrix.name }}
|
||||
cache-to: type=gha,mode=max,scope=${{ matrix.name }}
|
||||
21
Makefile
21
Makefile
|
|
@ -14,9 +14,7 @@ help: ## 显示帮助
|
|||
awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-15s\033[0m %s\n", $$1, $$2}'
|
||||
|
||||
dev: ## 启动本地开发环境(仅依赖服务)
|
||||
docker compose up -d
|
||||
@echo "Waiting for services to be healthy..."
|
||||
@sleep 5
|
||||
docker compose up -d --wait --remove-orphans
|
||||
@echo "Services ready."
|
||||
@echo "Start backend with: make dev-server"
|
||||
@echo "Start frontend with: make dev-web"
|
||||
|
|
@ -32,7 +30,7 @@ dev-all: ## 一键启动本地开发环境(依赖 + 后端 + 前端)
|
|||
echo "Backend already running with PID $$(cat $(DEV_SERVER_PID))"; \
|
||||
else \
|
||||
echo "Starting backend..."; \
|
||||
$(DEV_PROCESS) start --pid-file $(DEV_SERVER_PID) --log-file $(DEV_SERVER_LOG) --cwd server -- ./mvnw -pl skillhub-app spring-boot:run -Dspring-boot.run.profiles=local >/dev/null; \
|
||||
$(DEV_PROCESS) start --pid-file $(DEV_SERVER_PID) --log-file $(DEV_SERVER_LOG) --cwd server -- /bin/sh -lc './mvnw -pl skillhub-app -am install -DskipTests >/dev/null && exec ./mvnw -pl skillhub-app spring-boot:run -Dspring-boot.run.profiles=local' >/dev/null; \
|
||||
fi
|
||||
@if [ -f $(DEV_WEB_PID) ] && kill -0 "$$(cat $(DEV_WEB_PID))" 2>/dev/null; then \
|
||||
echo "Frontend already running with PID $$(cat $(DEV_WEB_PID))"; \
|
||||
|
|
@ -71,15 +69,18 @@ dev-all: ## 一键启动本地开发环境(依赖 + 后端 + 前端)
|
|||
@echo "Local environment is ready:"
|
||||
@echo " Web UI: $(DEV_WEB_URL)"
|
||||
@echo " Backend: $(DEV_API_URL)"
|
||||
@echo "Mock auth users:"
|
||||
@echo " local-user -> X-Mock-User-Id: local-user"
|
||||
@echo " local-admin -> X-Mock-User-Id: local-admin"
|
||||
@echo "Logs:"
|
||||
@echo " Backend: $(DEV_SERVER_LOG)"
|
||||
@echo " Frontend: $(DEV_WEB_LOG)"
|
||||
|
||||
dev-server: ## 启动后端开发服务器
|
||||
cd server && ./mvnw -pl skillhub-app spring-boot:run -Dspring-boot.run.profiles=local
|
||||
cd server && /bin/sh -lc './mvnw -pl skillhub-app -am install -DskipTests >/dev/null && exec ./mvnw -pl skillhub-app spring-boot:run -Dspring-boot.run.profiles=local'
|
||||
|
||||
dev-down: ## 停止本地开发环境
|
||||
docker compose down
|
||||
docker compose down --remove-orphans
|
||||
|
||||
dev-all-down: ## 停止本地开发环境(依赖 + 后端 + 前端)
|
||||
@$(DEV_PROCESS) stop --pid-file $(DEV_SERVER_PID)
|
||||
|
|
@ -89,7 +90,7 @@ dev-all-down: ## 停止本地开发环境(依赖 + 后端 + 前端)
|
|||
dev-all-reset: ## 重置本地开发环境(清理依赖数据卷后重新启动)
|
||||
@$(DEV_PROCESS) stop --pid-file $(DEV_SERVER_PID)
|
||||
@$(DEV_PROCESS) stop --pid-file $(DEV_WEB_PID)
|
||||
docker compose down -v
|
||||
docker compose down -v --remove-orphans
|
||||
rm -rf $(DEV_DIR)
|
||||
@$(MAKE) dev-all
|
||||
|
||||
|
|
@ -127,8 +128,6 @@ lint-web: ## 前端代码检查
|
|||
cd web && pnpm run lint
|
||||
|
||||
db-reset: ## 重置数据库
|
||||
docker compose down -v
|
||||
docker compose up -d postgres
|
||||
@echo "Waiting for postgres..."
|
||||
@sleep 3
|
||||
docker compose down -v --remove-orphans
|
||||
docker compose up -d --wait --remove-orphans postgres
|
||||
cd server && ./mvnw flyway:migrate -pl skillhub-app
|
||||
|
|
|
|||
60
README.md
60
README.md
|
|
@ -48,6 +48,13 @@ Then open:
|
|||
- Web UI: `http://localhost:3000`
|
||||
- Backend API: `http://localhost:8080`
|
||||
|
||||
Local profile seeds two mock-auth users automatically:
|
||||
|
||||
- `local-user` for normal publishing and namespace operations
|
||||
- `local-admin` with `SUPER_ADMIN` for review and admin flows
|
||||
|
||||
Use them with the `X-Mock-User-Id` header in local development.
|
||||
|
||||
Stop everything with:
|
||||
|
||||
```bash
|
||||
|
|
@ -62,6 +69,57 @@ make dev-all-reset
|
|||
|
||||
Run `make help` to see all available commands.
|
||||
|
||||
### Container Runtime
|
||||
|
||||
Published runtime images are built by GitHub Actions and pushed to GHCR.
|
||||
This is the supported path for anyone who wants a ready-to-use local
|
||||
environment without building the backend or frontend on their machine.
|
||||
Published images target both `linux/amd64` and `linux/arm64`.
|
||||
|
||||
1. Copy the runtime environment template.
|
||||
2. Pick an image tag.
|
||||
3. Start the stack with Docker Compose.
|
||||
|
||||
```bash
|
||||
cp .env.release.example .env.release
|
||||
```
|
||||
|
||||
Recommended image tags:
|
||||
|
||||
- `SKILLHUB_VERSION=edge` for the latest `main` build
|
||||
- `SKILLHUB_VERSION=vX.Y.Z` for a fixed release
|
||||
|
||||
Start the runtime:
|
||||
|
||||
```bash
|
||||
docker compose --env-file .env.release -f compose.release.yml up -d
|
||||
```
|
||||
|
||||
Then open:
|
||||
|
||||
- Web UI: `http://localhost`
|
||||
- Backend API: `http://localhost:8080`
|
||||
|
||||
Stop it with:
|
||||
|
||||
```bash
|
||||
docker compose --env-file .env.release -f compose.release.yml down
|
||||
```
|
||||
|
||||
The runtime stack uses its own Compose project name, so it does not
|
||||
collide with containers from `make dev-all`.
|
||||
|
||||
The runtime uses the existing `local,docker` profile combination so it
|
||||
is immediately usable with the same mock-auth flow as local development.
|
||||
Available seeded users:
|
||||
|
||||
- `local-user`
|
||||
- `local-admin`
|
||||
|
||||
Pass `X-Mock-User-Id` to the backend when you need an authenticated
|
||||
session without configuring GitHub OAuth. If the GHCR package remains
|
||||
private, run `docker login ghcr.io` before `docker compose up -d`.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
|
|
@ -82,7 +140,7 @@ Run `make help` to see all available commands.
|
|||
┌────────────┼────────────┐
|
||||
│ │ │
|
||||
┌──────▼───┐ ┌─────▼────┐ ┌───▼────┐
|
||||
│PostgreSQL│ │ Redis │ │ MinIO │
|
||||
│PostgreSQL│ │ Redis │ │ Storage │
|
||||
└──────────┘ └──────────┘ └────────┘
|
||||
```
|
||||
|
||||
|
|
|
|||
78
compose.release.yml
Normal file
78
compose.release.yml
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
name: skillhub-release
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${POSTGRES_PORT:-5432}:5432"
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB:-skillhub}
|
||||
POSTGRES_USER: ${POSTGRES_USER:-skillhub}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-skillhub_demo}
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-skillhub} -d ${POSTGRES_DB:-skillhub}"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${REDIS_PORT:-6379}:6379"
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
server:
|
||||
image: ${SKILLHUB_SERVER_IMAGE:-ghcr.io/iflytek/skillhub-server}:${SKILLHUB_VERSION:-edge}
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${API_PORT:-8080}:8080"
|
||||
environment:
|
||||
SPRING_PROFILES_ACTIVE: local,docker
|
||||
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/${POSTGRES_DB:-skillhub}
|
||||
SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER:-skillhub}
|
||||
SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD:-skillhub_demo}
|
||||
SPRING_DATA_REDIS_HOST: redis
|
||||
SPRING_DATA_REDIS_PORT: 6379
|
||||
STORAGE_BASE_PATH: /var/lib/skillhub/storage
|
||||
OAUTH2_GITHUB_CLIENT_ID: ${OAUTH2_GITHUB_CLIENT_ID:-local-placeholder}
|
||||
OAUTH2_GITHUB_CLIENT_SECRET: ${OAUTH2_GITHUB_CLIENT_SECRET:-local-placeholder}
|
||||
volumes:
|
||||
- skillhub_storage:/var/lib/skillhub/storage
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:8080/actuator/health"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
start_period: 60s
|
||||
|
||||
web:
|
||||
image: ${SKILLHUB_WEB_IMAGE:-ghcr.io/iflytek/skillhub-web}:${SKILLHUB_VERSION:-edge}
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${WEB_PORT:-80}:80"
|
||||
depends_on:
|
||||
server:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost/nginx-health"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
start_period: 10s
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
skillhub_storage:
|
||||
|
|
@ -1,86 +0,0 @@
|
|||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
ports:
|
||||
- "5432:5432"
|
||||
environment:
|
||||
POSTGRES_DB: skillhub
|
||||
POSTGRES_USER: skillhub
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD:-skillhub_prod}
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U skillhub"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
ports:
|
||||
- "6379:6379"
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
minio:
|
||||
image: minio/minio:latest
|
||||
ports:
|
||||
- "9000:9000"
|
||||
- "9001:9001"
|
||||
environment:
|
||||
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin}
|
||||
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin}
|
||||
command: server /data --console-address ":9001"
|
||||
volumes:
|
||||
- minio_data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
server:
|
||||
build:
|
||||
context: ./server
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- "8080:8080"
|
||||
environment:
|
||||
SPRING_PROFILES_ACTIVE: prod
|
||||
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/skillhub
|
||||
SPRING_DATASOURCE_USERNAME: skillhub
|
||||
SPRING_DATASOURCE_PASSWORD: ${DB_PASSWORD:-skillhub_prod}
|
||||
SPRING_DATA_REDIS_HOST: redis
|
||||
SPRING_DATA_REDIS_PORT: 6379
|
||||
OAUTH2_GITHUB_CLIENT_ID: ${OAUTH2_GITHUB_CLIENT_ID}
|
||||
OAUTH2_GITHUB_CLIENT_SECRET: ${OAUTH2_GITHUB_CLIENT_SECRET}
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
minio:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:8080/actuator/health"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
start_period: 30s
|
||||
|
||||
web:
|
||||
build:
|
||||
context: ./web
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- "80:80"
|
||||
depends_on:
|
||||
server:
|
||||
condition: service_healthy
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
minio_data:
|
||||
|
|
@ -114,6 +114,9 @@ skillhub/
|
|||
│ ├── Dockerfile # 前端多阶段构建
|
||||
│ └── nginx.conf # Nginx 配置(SPA 路由 + API 反向代理)
|
||||
├── docker-compose.yml # 本地开发依赖服务(PostgreSQL/Redis/MinIO)
|
||||
├── compose.release.yml # 单机运行时编排(发布镜像 + PostgreSQL + Redis)
|
||||
├── .env.release.example # 单机运行时环境变量模板
|
||||
├── .github/workflows/ # GitHub Actions 镜像发布流程
|
||||
├── Makefile # 顶层开发编排(dev / dev-all / build)
|
||||
├── docs/ # 设计文档
|
||||
└── README.md
|
||||
|
|
@ -123,10 +126,20 @@ skillhub/
|
|||
|
||||
## 8. 部署架构
|
||||
|
||||
同域部署,统一入口:
|
||||
- `https://skills.example.com/` → 前端静态资源
|
||||
- `https://skills.example.com/api/*` → 反向代理到 Spring Boot
|
||||
- 生产环境通过 Nginx 或网关统一接入
|
||||
部署模型收敛为两条路径:
|
||||
|
||||
- 开发路径:`make dev-all`。前后端在宿主机运行,`docker-compose.yml` 只负责 PostgreSQL、Redis、MinIO。
|
||||
- 交付路径:GitHub Actions 构建并发布 `server` / `web` 镜像;用户通过 `compose.release.yml` 在本地一键拉起前后端容器和基础服务。
|
||||
- 发布镜像为多架构 manifest,至少覆盖 `linux/amd64` 与 `linux/arm64`。
|
||||
|
||||
单机运行时统一入口:
|
||||
- `http://localhost/` → Web 容器(Nginx)
|
||||
- `http://localhost/api/*` → Web 容器反向代理到 Spring Boot
|
||||
- `http://localhost:8080/actuator/health` → 后端健康检查
|
||||
|
||||
单机运行时使用 `local,docker` profile 组合:
|
||||
- `local` 提供 mock 登录和种子账号,保证拉起即用
|
||||
- `docker` 负责将数据库、Redis 地址切换到 Compose 网络
|
||||
|
||||
## 9. 分布式环境要求
|
||||
|
||||
|
|
@ -148,3 +161,5 @@ skillhub/
|
|||
- 缓存/Session:Spring Session + Redis
|
||||
- 数据库迁移:Flyway
|
||||
- 认证:Spring Security OAuth2 Client(一期 GitHub)
|
||||
- 镜像发布:GitHub Actions 推送至 GHCR,默认维护 `edge` 与语义化版本标签
|
||||
- 运行时兼容:发布镜像默认输出 `linux/amd64` + `linux/arm64` 多架构 manifest
|
||||
|
|
|
|||
|
|
@ -1,350 +1,179 @@
|
|||
# skillhub 部署架构与运维
|
||||
|
||||
## 1 K8s 部署拓扑
|
||||
## 1 运行模型
|
||||
|
||||
当前仓库只保留两种运行方式:
|
||||
|
||||
- 开发环境:`make dev-all`
|
||||
- 前端和后端运行在宿主机
|
||||
- `docker-compose.yml` 只负责 PostgreSQL、Redis、MinIO
|
||||
- 单机交付环境:`docker compose --env-file .env.release -f compose.release.yml up -d`
|
||||
- 前端和后端都运行在容器内
|
||||
- 使用 GitHub Actions 发布到 GHCR 的镜像
|
||||
- 默认发布 `linux/amd64` 与 `linux/arm64` 多架构镜像
|
||||
- PostgreSQL、Redis 与应用容器一起通过 Compose 启动
|
||||
|
||||
不再维护本地构建整套 demo 容器的中间模式,也不再保留 `docker-compose.prod.yml`。
|
||||
|
||||
## 2 单机交付拓扑
|
||||
|
||||
```
|
||||
┌─────────────┐
|
||||
│ Ingress │
|
||||
│ (Nginx) │
|
||||
└──────┬──────┘
|
||||
│
|
||||
┌────────────┴────────────┐
|
||||
│ /api/* │ /*
|
||||
▼ ▼
|
||||
┌──────────────────┐ ┌──────────────────┐
|
||||
│ Spring Boot │ │ Nginx / CDN │
|
||||
│ replicas: 2+ │ │ 静态资源 │
|
||||
└────────┬─────────┘ └──────────────────┘
|
||||
│
|
||||
┌────────┴──────────────────────┐
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌────────┐ ┌────────┐ ┌──────────────┐
|
||||
│ PostgreSQL│ │ Redis │ │ S3 / MinIO │
|
||||
│ (主从) │ │ │ │ │
|
||||
└────────┘ └────────┘ └──────────────┘
|
||||
┌──────────────┐
|
||||
│ Browser / CLI│
|
||||
└──────┬───────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────┐
|
||||
│ Web/Nginx │ published image
|
||||
└──────┬───────┘
|
||||
│ /api/*
|
||||
▼
|
||||
┌──────────────┐
|
||||
│ Spring Boot │ published image
|
||||
└───┬────┬─────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
PostgreSQL Redis
|
||||
```
|
||||
|
||||
## 2 服务配置
|
||||
说明:
|
||||
- Web 容器提供静态资源,并将 `/api/*`、`/oauth2/*`、`/.well-known/*` 反代到后端
|
||||
- 后端运行 `local,docker` profile 组合
|
||||
- 技能包文件默认落在容器卷 `skillhub_storage`,保证单机环境开箱即用
|
||||
|
||||
- 无状态设计,所有状态存储在 PostgreSQL / Redis / S3
|
||||
- 健康检查:`/actuator/health`(liveness + readiness 分离)
|
||||
- 优雅停机:`spring.lifecycle.timeout-per-shutdown-phase=30s`
|
||||
- JVM:`-XX:MaxRAMPercentage=75.0`
|
||||
## 3 Profile 约定
|
||||
|
||||
## 3 环境 Profile
|
||||
|
||||
| Profile | 用途 | 特点 |
|
||||
| Profile | 用途 | 说明 |
|
||||
|---------|------|------|
|
||||
| `local` | 本地开发 | Docker Compose 一键启动(PostgreSQL/Redis/MinIO),Mock OAuth(见下方说明) |
|
||||
| `dev` | 开发环境 | 共享基础设施,GitHub OAuth 测试应用 |
|
||||
| `staging` | 预发布 | 与生产同构 |
|
||||
| `prod` | 生产 | 多 Pod,完整基础设施 |
|
||||
| `local` | 本地源码开发能力 | 启用 mock 登录、开发种子账号、调试日志 |
|
||||
| `docker` | 容器网络适配 | 将数据库和 Redis 地址切换到 Compose 内网 |
|
||||
|
||||
### 本地开发 Mock 登录
|
||||
单机交付环境使用 `SPRING_PROFILES_ACTIVE=local,docker`,原因很明确:
|
||||
|
||||
`local` profile 下提供两种开发登录方式:
|
||||
- 这是当前唯一能保证“镜像拉起后直接可用”的 profile 组合
|
||||
- 用户无需先配置 GitHub OAuth,先用 mock 身份即可浏览和联调主要流程
|
||||
- 后续如果引入专用 `runtime` / `demo` profile,可以替换这层组合,但当前方案不再新增第三条部署路径
|
||||
|
||||
1. **MockAuthFilter**(默认):通过 `X-Mock-User-Id` Header 模拟登录,自动创建 Session,无需真实 OAuth 流程
|
||||
2. **GitHub OAuth 测试应用**:配置 `OAUTH2_GITHUB_CLIENT_ID` / `OAUTH2_GITHUB_CLIENT_SECRET` 后可走真实 OAuth 流程(GitHub 支持 `http://localhost` 回调)
|
||||
默认可用账号:
|
||||
|
||||
MockAuthFilter 仅在 `local` profile 激活,通过 `@Profile("local")` 注解保证不会泄漏到其他环境。
|
||||
- `local-user`
|
||||
- `local-admin`
|
||||
|
||||
### Docker Compose 说明
|
||||
鉴权方式:
|
||||
|
||||
当前推荐的本地启动入口是 `make dev-all`。Docker Compose 在当前项目里主要承担本地依赖服务启动。
|
||||
- 向后端请求携带 `X-Mock-User-Id: local-user`
|
||||
- 或 `X-Mock-User-Id: local-admin`
|
||||
|
||||
## 4 开发环境
|
||||
|
||||
开发入口保持不变:
|
||||
|
||||
```bash
|
||||
make dev-all
|
||||
```
|
||||
|
||||
行为:
|
||||
|
||||
- `docker-compose.yml` 启动 PostgreSQL、Redis、MinIO
|
||||
- `server` 在宿主机通过 Maven Wrapper 启动
|
||||
- `web` 在宿主机通过 Vite 启动
|
||||
|
||||
常用命令:
|
||||
|
||||
```bash
|
||||
make dev
|
||||
make dev-all
|
||||
make dev-down
|
||||
make dev-all-down
|
||||
make dev-all-reset
|
||||
```
|
||||
|
||||
#### docker-compose.yml — 本地开发(仅依赖服务)
|
||||
## 5 单机交付环境
|
||||
|
||||
本地开发时前后端在宿主机运行,Docker Compose 只拉起依赖服务:
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml(项目根目录)
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
ports:
|
||||
- "5432:5432"
|
||||
environment:
|
||||
POSTGRES_DB: skillhub
|
||||
POSTGRES_USER: skillhub
|
||||
POSTGRES_PASSWORD: skillhub_dev
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
ports:
|
||||
- "6379:6379"
|
||||
|
||||
minio:
|
||||
image: minio/minio:latest
|
||||
ports:
|
||||
- "9000:9000"
|
||||
- "9001:9001" # MinIO Console
|
||||
environment:
|
||||
MINIO_ROOT_USER: minioadmin
|
||||
MINIO_ROOT_PASSWORD: minioadmin
|
||||
command: server /data --console-address ":9001"
|
||||
volumes:
|
||||
- minio_data:/data
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
minio_data:
|
||||
```
|
||||
|
||||
生产环境文档不再提供 Compose 一键部署入口。当前仓库只保留本地开发所需的 `docker-compose.yml`,正式部署以镜像构建 + K8s 编排为准。
|
||||
|
||||
#### 前后端 Dockerfile
|
||||
|
||||
后端 Dockerfile(`server/Dockerfile`):
|
||||
```dockerfile
|
||||
FROM maven:3.9-eclipse-temurin-21 AS build
|
||||
WORKDIR /app
|
||||
COPY pom.xml .
|
||||
COPY skillhub-app/pom.xml skillhub-app/
|
||||
COPY skillhub-domain/pom.xml skillhub-domain/
|
||||
COPY skillhub-auth/pom.xml skillhub-auth/
|
||||
COPY skillhub-search/pom.xml skillhub-search/
|
||||
COPY skillhub-storage/pom.xml skillhub-storage/
|
||||
COPY skillhub-infra/pom.xml skillhub-infra/
|
||||
RUN mvn dependency:go-offline -B
|
||||
COPY . .
|
||||
RUN mvn package -DskipTests -B
|
||||
|
||||
FROM eclipse-temurin:21-jre-alpine
|
||||
WORKDIR /app
|
||||
COPY --from=build /app/skillhub-app/target/*.jar app.jar
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75.0", "-jar", "app.jar"]
|
||||
```
|
||||
|
||||
前端 Dockerfile(`web/Dockerfile`):
|
||||
```dockerfile
|
||||
FROM node:20-alpine AS build
|
||||
WORKDIR /app
|
||||
RUN corepack enable
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
COPY . .
|
||||
RUN pnpm build
|
||||
|
||||
FROM nginx:alpine
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
EXPOSE 80
|
||||
```
|
||||
|
||||
前端 Nginx 配置(`web/nginx.conf`):
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# SPA 路由回退
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# API 反向代理到后端
|
||||
location /api/ {
|
||||
proxy_pass http://server:8080;
|
||||
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;
|
||||
}
|
||||
|
||||
# OAuth2 回调反向代理
|
||||
location /oauth2/ {
|
||||
proxy_pass http://server:8080;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
|
||||
location /login/oauth2/ {
|
||||
proxy_pass http://server:8080;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
|
||||
# Well-known 发现端点
|
||||
location /.well-known/ {
|
||||
proxy_pass http://server:8080;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Spring Boot 配置文件分层
|
||||
|
||||
```
|
||||
server/skillhub-app/src/main/resources/
|
||||
├── application.yml # 公共配置(所有 profile 共享)
|
||||
├── application-local.yml # 本地开发(Docker Compose 服务地址)
|
||||
├── application-dev.yml # 开发环境
|
||||
├── application-staging.yml # 预发布
|
||||
└── application-prod.yml # 生产
|
||||
```
|
||||
|
||||
`application.yml`(公共配置):
|
||||
```yaml
|
||||
spring:
|
||||
application:
|
||||
name: skillhub
|
||||
jpa:
|
||||
open-in-view: false
|
||||
hibernate:
|
||||
ddl-auto: validate # 由 Flyway 管理 schema,Hibernate 仅校验
|
||||
properties:
|
||||
hibernate:
|
||||
dialect: org.hibernate.dialect.PostgreSQLDialect
|
||||
flyway:
|
||||
enabled: true
|
||||
locations: classpath:db/migration
|
||||
|
||||
server:
|
||||
shutdown: graceful
|
||||
|
||||
spring.lifecycle.timeout-per-shutdown-phase: 30s
|
||||
```
|
||||
|
||||
`application-local.yml`(本地开发,对应 Docker Compose):
|
||||
```yaml
|
||||
spring:
|
||||
datasource:
|
||||
url: jdbc:postgresql://localhost:5432/skillhub
|
||||
username: skillhub
|
||||
password: skillhub_dev
|
||||
data:
|
||||
redis:
|
||||
host: localhost
|
||||
port: 6379
|
||||
jpa:
|
||||
show-sql: true
|
||||
|
||||
skillhub:
|
||||
storage:
|
||||
type: s3
|
||||
endpoint: http://localhost:9000
|
||||
access-key: minioadmin
|
||||
secret-key: minioadmin
|
||||
bucket: skillhub
|
||||
region: us-east-1
|
||||
access-policy:
|
||||
mode: OPEN # 本地开发默认开放准入
|
||||
```
|
||||
|
||||
`application-prod.yml`(生产环境,凭证从环境变量/K8s Secret 注入):
|
||||
```yaml
|
||||
spring:
|
||||
datasource:
|
||||
url: ${DATABASE_URL}
|
||||
username: ${DATABASE_USERNAME}
|
||||
password: ${DATABASE_PASSWORD}
|
||||
data:
|
||||
redis:
|
||||
host: ${REDIS_HOST}
|
||||
port: ${REDIS_PORT:6379}
|
||||
jpa:
|
||||
show-sql: false
|
||||
|
||||
skillhub:
|
||||
storage:
|
||||
type: s3
|
||||
endpoint: ${S3_ENDPOINT}
|
||||
access-key: ${S3_ACCESS_KEY}
|
||||
secret-key: ${S3_SECRET_KEY}
|
||||
bucket: ${S3_BUCKET:skillhub}
|
||||
region: ${S3_REGION:us-east-1}
|
||||
```
|
||||
|
||||
### 本地开发启动流程
|
||||
### 5.1 启动
|
||||
|
||||
```bash
|
||||
# 一键启动依赖 + 后端 + 前端
|
||||
make dev-all
|
||||
cp .env.release.example .env.release
|
||||
docker compose --env-file .env.release -f compose.release.yml up -d
|
||||
```
|
||||
|
||||
启动后可直接访问:
|
||||
默认访问地址:
|
||||
|
||||
- Web UI: `http://localhost:3000`
|
||||
- Web UI: `http://localhost`
|
||||
- Backend API: `http://localhost:8080`
|
||||
|
||||
停止:
|
||||
### 5.2 关键文件
|
||||
|
||||
```bash
|
||||
make dev-all-down
|
||||
```
|
||||
- `compose.release.yml`
|
||||
- 使用发布镜像,不在用户机器上执行本地构建
|
||||
- 负责拉起 PostgreSQL、Redis、server、web
|
||||
- 使用独立 Compose project name,避免与开发环境容器互相污染
|
||||
- `.env.release.example`
|
||||
- 运行时变量模板
|
||||
- 包含镜像名、镜像版本、端口和数据库凭证
|
||||
|
||||
如需分步启动:
|
||||
### 5.3 镜像标签约定
|
||||
|
||||
```bash
|
||||
make dev # 仅依赖服务
|
||||
make dev-server # 仅后端
|
||||
make dev-web # 仅前端
|
||||
```
|
||||
- `edge`
|
||||
- `main` 分支最新构建
|
||||
- 用于内部持续验证
|
||||
- `vX.Y.Z`
|
||||
- 对应 Git tag
|
||||
- 用于稳定版本交付
|
||||
- `latest`
|
||||
- 仅在语义化版本 tag 发布时更新
|
||||
|
||||
### Makefile 命令
|
||||
推荐:
|
||||
|
||||
```bash
|
||||
make dev # 仅启动本地依赖服务
|
||||
make dev-all # 一键启动本地依赖 + 后端 + 前端
|
||||
make dev-down # 停止本地依赖服务
|
||||
make dev-all-down # 停止本地依赖 + 后端 + 前端
|
||||
make build # 构建后端
|
||||
make generate-api # 生成 OpenAPI 类型
|
||||
```
|
||||
- 团队内部试用:`SKILLHUB_VERSION=edge`
|
||||
- 对外演示或文档引用:固定为某个 `vX.Y.Z`
|
||||
|
||||
## 4 配置管理
|
||||
## 6 GitHub Actions 发布流程
|
||||
|
||||
- 敏感配置:K8s Secret(数据库/Redis/S3 凭证、OAuth2 Client ID/Secret)
|
||||
- 非敏感配置:K8s ConfigMap(文件大小限制、Session TTL 等)
|
||||
发布工作流文件:`.github/workflows/publish-images.yml`
|
||||
|
||||
## 5 可观测性
|
||||
触发条件:
|
||||
|
||||
- push 到 `main`
|
||||
- push 语义化版本 tag,例如 `v1.2.0`
|
||||
- 手动 `workflow_dispatch`
|
||||
|
||||
流程:
|
||||
|
||||
1. 检出代码
|
||||
2. 登录 GHCR
|
||||
3. 分别构建 `server/Dockerfile` 与 `web/Dockerfile`
|
||||
4. 推送镜像:
|
||||
- `ghcr.io/iflytek/skillhub-server`
|
||||
- `ghcr.io/iflytek/skillhub-web`
|
||||
5. 写入 `edge` / `vX.Y.Z` / `latest` / `sha-*` 标签
|
||||
6. 同时发布 `linux/amd64` 与 `linux/arm64` manifest,避免 Apple Silicon / ARM 主机依赖模拟层
|
||||
|
||||
## 7 配置管理
|
||||
|
||||
开发环境:
|
||||
|
||||
- 本地命令与 `docker-compose.yml`
|
||||
- 非敏感默认值可直接落库或写入本地配置
|
||||
|
||||
单机交付环境:
|
||||
|
||||
- 使用 `.env.release` 管理 Compose 变量
|
||||
- 如果 GHCR 包保持私有,用户需要先 `docker login ghcr.io`
|
||||
- 如果要开放真实登录,再补充 `OAUTH2_GITHUB_CLIENT_ID` / `OAUTH2_GITHUB_CLIENT_SECRET`
|
||||
|
||||
## 8 可观测性
|
||||
|
||||
| 维度 | 方案 |
|
||||
|------|------|
|
||||
| 日志 | JSON 格式 stdout,包含 traceId/requestId |
|
||||
| 指标 | Actuator + Micrometer → Prometheus |
|
||||
| 链路追踪 | 一期 requestId 透传,后续接 Jaeger/Zipkin |
|
||||
| 告警 | 基于 Prometheus(5xx 率、延迟 P99、Pod 重启) |
|
||||
| 健康检查 | `web/nginx-health`、`server/actuator/health` |
|
||||
| 日志 | 容器 stdout / stderr |
|
||||
| 指标 | Spring Boot Actuator,后续可接 Prometheus |
|
||||
|
||||
requestId 透传:Ingress 注入 → Spring Filter 读取放入 MDC → 日志自动携带 → 响应 Header 回传。
|
||||
## 9 数据迁移
|
||||
|
||||
## 6 构建与发布
|
||||
Flyway 仍是唯一 schema 变更入口:
|
||||
|
||||
### CI Pipeline 构建
|
||||
|
||||
```
|
||||
代码提交 → CI Pipeline
|
||||
├── server: mvn package → JAR
|
||||
└── web: pnpm build → dist/
|
||||
│
|
||||
▼
|
||||
Docker 多阶段构建
|
||||
├── server → eclipse-temurin:21-jre-alpine
|
||||
└── web → nginx:alpine
|
||||
│
|
||||
▼
|
||||
推送镜像 → K8s 滚动更新
|
||||
```
|
||||
|
||||
Makefile 顶层命令:`make dev`, `make dev-all`, `make dev-down`, `make dev-all-down`, `make build`, `make generate-api`
|
||||
|
||||
## 7 数据库迁移
|
||||
|
||||
Flyway 管理 schema 变更:
|
||||
- 脚本路径:`server/skillhub-app/src/main/resources/db/migration/`
|
||||
- 路径:`server/skillhub-app/src/main/resources/db/migration/`
|
||||
- 命名:`V{version}__{description}.sql`
|
||||
- 多 Pod 安全:Flyway 自带数据库锁
|
||||
- 启动策略:应用容器启动时自动执行迁移
|
||||
|
|
|
|||
138
scripts/dev_process.py
Normal file
138
scripts/dev_process.py
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
#!/usr/bin/env python3
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def is_running(pid: int) -> bool:
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
except OSError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def read_pid(pid_file: Path) -> int | None:
|
||||
if not pid_file.exists():
|
||||
return None
|
||||
content = pid_file.read_text(encoding="utf-8").strip()
|
||||
if not content:
|
||||
return None
|
||||
try:
|
||||
return int(content)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def write_pid(pid_file: Path, pid: int) -> None:
|
||||
pid_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
pid_file.write_text(f"{pid}\n", encoding="utf-8")
|
||||
|
||||
|
||||
def start_process(args: argparse.Namespace) -> int:
|
||||
pid_file = Path(args.pid_file)
|
||||
log_file = Path(args.log_file)
|
||||
cwd = Path(args.cwd)
|
||||
existing_pid = read_pid(pid_file)
|
||||
if existing_pid and is_running(existing_pid):
|
||||
print(existing_pid)
|
||||
return 0
|
||||
|
||||
pid_file.unlink(missing_ok=True)
|
||||
log_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
command = list(args.command)
|
||||
if command and command[0] == "--":
|
||||
command = command[1:]
|
||||
|
||||
with log_file.open("ab") as log_handle, open(os.devnull, "rb") as devnull:
|
||||
process = subprocess.Popen(
|
||||
command,
|
||||
cwd=cwd,
|
||||
stdin=devnull,
|
||||
stdout=log_handle,
|
||||
stderr=subprocess.STDOUT,
|
||||
start_new_session=True,
|
||||
)
|
||||
|
||||
write_pid(pid_file, process.pid)
|
||||
print(process.pid)
|
||||
return 0
|
||||
|
||||
|
||||
def stop_process(args: argparse.Namespace) -> int:
|
||||
pid_file = Path(args.pid_file)
|
||||
pid = read_pid(pid_file)
|
||||
if not pid:
|
||||
return 0
|
||||
|
||||
if not is_running(pid):
|
||||
pid_file.unlink(missing_ok=True)
|
||||
return 0
|
||||
|
||||
os.kill(pid, signal.SIGTERM)
|
||||
deadline = time.time() + args.timeout
|
||||
while time.time() < deadline:
|
||||
if not is_running(pid):
|
||||
pid_file.unlink(missing_ok=True)
|
||||
return 0
|
||||
time.sleep(0.2)
|
||||
|
||||
os.kill(pid, signal.SIGKILL)
|
||||
pid_file.unlink(missing_ok=True)
|
||||
return 0
|
||||
|
||||
|
||||
def status_process(args: argparse.Namespace) -> int:
|
||||
pid_file = Path(args.pid_file)
|
||||
pid = read_pid(pid_file)
|
||||
if not pid:
|
||||
return 1
|
||||
if not is_running(pid):
|
||||
pid_file.unlink(missing_ok=True)
|
||||
return 1
|
||||
print(pid)
|
||||
return 0
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description="Manage detached dev processes.")
|
||||
subparsers = parser.add_subparsers(dest="action", required=True)
|
||||
|
||||
start_parser = subparsers.add_parser("start")
|
||||
start_parser.add_argument("--pid-file", required=True)
|
||||
start_parser.add_argument("--log-file", required=True)
|
||||
start_parser.add_argument("--cwd", required=True)
|
||||
start_parser.add_argument("command", nargs=argparse.REMAINDER)
|
||||
|
||||
stop_parser = subparsers.add_parser("stop")
|
||||
stop_parser.add_argument("--pid-file", required=True)
|
||||
stop_parser.add_argument("--timeout", type=float, default=10.0)
|
||||
|
||||
status_parser = subparsers.add_parser("status")
|
||||
status_parser.add_argument("--pid-file", required=True)
|
||||
return parser
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.action == "start":
|
||||
if not args.command:
|
||||
parser.error("start requires a command")
|
||||
return start_process(args)
|
||||
if args.action == "stop":
|
||||
return stop_process(args)
|
||||
if args.action == "status":
|
||||
return status_process(args)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
|
@ -0,0 +1,108 @@
|
|||
package com.iflytek.skillhub.bootstrap;
|
||||
|
||||
import com.iflytek.skillhub.auth.entity.Role;
|
||||
import com.iflytek.skillhub.auth.entity.UserRoleBinding;
|
||||
import com.iflytek.skillhub.auth.repository.RoleRepository;
|
||||
import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository;
|
||||
import com.iflytek.skillhub.domain.namespace.Namespace;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceMember;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
|
||||
import com.iflytek.skillhub.domain.user.UserAccount;
|
||||
import com.iflytek.skillhub.domain.user.UserAccountRepository;
|
||||
import com.iflytek.skillhub.domain.user.UserStatus;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.boot.ApplicationArguments;
|
||||
import org.springframework.boot.ApplicationRunner;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Component
|
||||
@Profile("local")
|
||||
public class LocalDevDataInitializer implements ApplicationRunner {
|
||||
|
||||
public static final String LOCAL_USER_ID = "local-user";
|
||||
public static final String LOCAL_ADMIN_ID = "local-admin";
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(LocalDevDataInitializer.class);
|
||||
|
||||
private final UserAccountRepository userAccountRepository;
|
||||
private final NamespaceRepository namespaceRepository;
|
||||
private final NamespaceMemberRepository namespaceMemberRepository;
|
||||
private final RoleRepository roleRepository;
|
||||
private final UserRoleBindingRepository userRoleBindingRepository;
|
||||
|
||||
public LocalDevDataInitializer(UserAccountRepository userAccountRepository,
|
||||
NamespaceRepository namespaceRepository,
|
||||
NamespaceMemberRepository namespaceMemberRepository,
|
||||
RoleRepository roleRepository,
|
||||
UserRoleBindingRepository userRoleBindingRepository) {
|
||||
this.userAccountRepository = userAccountRepository;
|
||||
this.namespaceRepository = namespaceRepository;
|
||||
this.namespaceMemberRepository = namespaceMemberRepository;
|
||||
this.roleRepository = roleRepository;
|
||||
this.userRoleBindingRepository = userRoleBindingRepository;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void run(ApplicationArguments args) {
|
||||
UserAccount localUser = ensureUser(
|
||||
LOCAL_USER_ID,
|
||||
"Local Developer",
|
||||
"local-user@example.test"
|
||||
);
|
||||
UserAccount localAdmin = ensureUser(
|
||||
LOCAL_ADMIN_ID,
|
||||
"Local Admin",
|
||||
"local-admin@example.test"
|
||||
);
|
||||
|
||||
Namespace globalNamespace = namespaceRepository.findBySlug("global")
|
||||
.orElseThrow(() -> new IllegalStateException("Missing built-in global namespace"));
|
||||
|
||||
ensureMembership(globalNamespace.getId(), localUser.getId(), NamespaceRole.OWNER);
|
||||
ensureMembership(globalNamespace.getId(), localAdmin.getId(), NamespaceRole.OWNER);
|
||||
ensureRole(localAdmin.getId(), "SUPER_ADMIN");
|
||||
|
||||
log.info("Local dev accounts ready: {} / {}", LOCAL_USER_ID, LOCAL_ADMIN_ID);
|
||||
}
|
||||
|
||||
private UserAccount ensureUser(String userId, String displayName, String email) {
|
||||
return userAccountRepository.findById(userId)
|
||||
.map(existing -> {
|
||||
existing.setDisplayName(displayName);
|
||||
existing.setEmail(email);
|
||||
existing.setStatus(UserStatus.ACTIVE);
|
||||
return userAccountRepository.save(existing);
|
||||
})
|
||||
.orElseGet(() -> userAccountRepository.save(
|
||||
new UserAccount(userId, displayName, email, null)
|
||||
));
|
||||
}
|
||||
|
||||
private void ensureMembership(Long namespaceId, String userId, NamespaceRole role) {
|
||||
NamespaceMember member = namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, userId)
|
||||
.orElseGet(() -> new NamespaceMember(namespaceId, userId, role));
|
||||
if (member.getRole() != role) {
|
||||
member.setRole(role);
|
||||
}
|
||||
namespaceMemberRepository.save(member);
|
||||
}
|
||||
|
||||
private void ensureRole(String userId, String roleCode) {
|
||||
boolean exists = userRoleBindingRepository.findByUserId(userId).stream()
|
||||
.map(binding -> binding.getRole().getCode())
|
||||
.anyMatch(roleCode::equals);
|
||||
if (exists) {
|
||||
return;
|
||||
}
|
||||
|
||||
Role role = roleRepository.findByCode(roleCode)
|
||||
.orElseThrow(() -> new IllegalStateException("Missing built-in role: " + roleCode));
|
||||
userRoleBindingRepository.save(new UserRoleBinding(userId, role));
|
||||
}
|
||||
}
|
||||
|
|
@ -58,7 +58,7 @@ public class PromotionController extends BaseApiController {
|
|||
PromotionRequest promotion = promotionService.submitPromotion(
|
||||
request.sourceSkillId(), request.sourceVersionId(),
|
||||
request.targetNamespaceId(), userId);
|
||||
return ok("response.success.create", toResponse(promotion));
|
||||
return ok("response.success.created", toResponse(promotion));
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/approve")
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ public class ReviewController extends BaseApiController {
|
|||
.orElseThrow();
|
||||
Skill skill = skillRepository.findById(sv.getSkillId()).orElseThrow();
|
||||
ReviewTask task = reviewService.submitReview(request.skillVersionId(), skill.getNamespaceId(), userId);
|
||||
return ok("response.success.create", toResponse(task));
|
||||
return ok("response.success.created", toResponse(task));
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/approve")
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import com.iflytek.skillhub.dto.ApiResponse;
|
|||
import com.iflytek.skillhub.dto.ApiResponseFactory;
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException;
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.slf4j.MDC;
|
||||
|
|
@ -43,6 +44,12 @@ public class GlobalExceptionHandler {
|
|||
apiResponseFactory.error(403, ex.messageCode(), ex.messageArgs()));
|
||||
}
|
||||
|
||||
@ExceptionHandler(DomainNotFoundException.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleDomainNotFound(DomainNotFoundException ex) {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(
|
||||
apiResponseFactory.error(404, ex.messageCode(), ex.messageArgs()));
|
||||
}
|
||||
|
||||
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleValidation(MethodArgumentNotValidException ex) {
|
||||
String msg = ex.getBindingResult().getFieldErrors().stream()
|
||||
|
|
|
|||
|
|
@ -51,6 +51,12 @@ error.skill.publish.package.invalid=Package validation failed: {0}
|
|||
error.skill.publish.skillMd.notFound=SKILL.md not found
|
||||
error.skill.publish.precheck.failed=Pre-publish validation failed: {0}
|
||||
error.skill.notFound=Skill not found: {0}
|
||||
skill.not_found=Skill not found: {0}
|
||||
skill_version.not_found=Skill version not found: {0}
|
||||
namespace.not_found=Namespace not found: {0}
|
||||
promotion.not_found=Promotion request not found: {0}
|
||||
review_task.not_found=Review task not found: {0}
|
||||
review_task.not_found_for_version=Review task not found for skill version: {0}
|
||||
error.skill.access.denied=Access denied to skill: {0}
|
||||
error.skill.status.notActive=Skill is not active
|
||||
error.skill.version.exists=Version already exists: {0}
|
||||
|
|
|
|||
|
|
@ -51,6 +51,12 @@ error.skill.publish.package.invalid=技能包校验失败:{0}
|
|||
error.skill.publish.skillMd.notFound=未找到 SKILL.md
|
||||
error.skill.publish.precheck.failed=预发布校验失败:{0}
|
||||
error.skill.notFound=未找到技能:{0}
|
||||
skill.not_found=未找到技能:{0}
|
||||
skill_version.not_found=未找到技能版本:{0}
|
||||
namespace.not_found=未找到命名空间:{0}
|
||||
promotion.not_found=未找到推广申请:{0}
|
||||
review_task.not_found=未找到审核任务:{0}
|
||||
review_task.not_found_for_version=未找到该技能版本对应的审核任务:{0}
|
||||
error.skill.access.denied=没有权限访问技能:{0}
|
||||
error.skill.status.notActive=技能未处于 ACTIVE 状态
|
||||
error.skill.version.exists=版本已存在:{0}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,98 @@
|
|||
package com.iflytek.skillhub.bootstrap;
|
||||
|
||||
import com.iflytek.skillhub.auth.entity.Role;
|
||||
import com.iflytek.skillhub.auth.entity.UserRoleBinding;
|
||||
import com.iflytek.skillhub.auth.repository.RoleRepository;
|
||||
import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository;
|
||||
import com.iflytek.skillhub.domain.namespace.Namespace;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceMember;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
|
||||
import com.iflytek.skillhub.domain.user.UserAccount;
|
||||
import com.iflytek.skillhub.domain.user.UserAccountRepository;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.springframework.boot.DefaultApplicationArguments;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class LocalDevDataInitializerTest {
|
||||
|
||||
@Mock private UserAccountRepository userAccountRepository;
|
||||
@Mock private NamespaceRepository namespaceRepository;
|
||||
@Mock private NamespaceMemberRepository namespaceMemberRepository;
|
||||
@Mock private RoleRepository roleRepository;
|
||||
@Mock private UserRoleBindingRepository userRoleBindingRepository;
|
||||
|
||||
private LocalDevDataInitializer initializer;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
initializer = new LocalDevDataInitializer(
|
||||
userAccountRepository,
|
||||
namespaceRepository,
|
||||
namespaceMemberRepository,
|
||||
roleRepository,
|
||||
userRoleBindingRepository
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldSeedLocalUsersGlobalMembershipAndSuperAdminRole() throws Exception {
|
||||
Namespace global = new Namespace("global", "Global", "system");
|
||||
setField(global, "id", 1L);
|
||||
|
||||
Role superAdminRole = new Role();
|
||||
setField(superAdminRole, "id", 1L);
|
||||
setField(superAdminRole, "code", "SUPER_ADMIN");
|
||||
|
||||
when(userAccountRepository.findById(LocalDevDataInitializer.LOCAL_USER_ID)).thenReturn(Optional.empty());
|
||||
when(userAccountRepository.findById(LocalDevDataInitializer.LOCAL_ADMIN_ID)).thenReturn(Optional.empty());
|
||||
when(userAccountRepository.save(any(UserAccount.class))).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(global));
|
||||
when(namespaceMemberRepository.findByNamespaceIdAndUserId(anyLong(), any())).thenReturn(Optional.empty());
|
||||
when(namespaceMemberRepository.save(any(NamespaceMember.class))).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
when(roleRepository.findByCode("SUPER_ADMIN")).thenReturn(Optional.of(superAdminRole));
|
||||
when(userRoleBindingRepository.findByUserId(LocalDevDataInitializer.LOCAL_ADMIN_ID)).thenReturn(List.of());
|
||||
|
||||
initializer.run(new DefaultApplicationArguments(new String[0]));
|
||||
|
||||
ArgumentCaptor<UserAccount> userCaptor = ArgumentCaptor.forClass(UserAccount.class);
|
||||
verify(userAccountRepository, times(2)).save(userCaptor.capture());
|
||||
List<UserAccount> savedUsers = userCaptor.getAllValues();
|
||||
assertTrue(savedUsers.stream().anyMatch(user -> LocalDevDataInitializer.LOCAL_USER_ID.equals(user.getId())));
|
||||
assertTrue(savedUsers.stream().anyMatch(user -> LocalDevDataInitializer.LOCAL_ADMIN_ID.equals(user.getId())));
|
||||
|
||||
ArgumentCaptor<NamespaceMember> memberCaptor = ArgumentCaptor.forClass(NamespaceMember.class);
|
||||
verify(namespaceMemberRepository, times(2)).save(memberCaptor.capture());
|
||||
assertEquals(
|
||||
List.of(LocalDevDataInitializer.LOCAL_USER_ID, LocalDevDataInitializer.LOCAL_ADMIN_ID),
|
||||
memberCaptor.getAllValues().stream().map(NamespaceMember::getUserId).toList()
|
||||
);
|
||||
assertTrue(memberCaptor.getAllValues().stream().allMatch(member -> member.getRole() == NamespaceRole.OWNER));
|
||||
|
||||
ArgumentCaptor<UserRoleBinding> roleBindingCaptor = ArgumentCaptor.forClass(UserRoleBinding.class);
|
||||
verify(userRoleBindingRepository).save(roleBindingCaptor.capture());
|
||||
assertEquals(LocalDevDataInitializer.LOCAL_ADMIN_ID, roleBindingCaptor.getValue().getUserId());
|
||||
assertEquals("SUPER_ADMIN", roleBindingCaptor.getValue().getRole().getCode());
|
||||
}
|
||||
|
||||
private static void setField(Object target, String fieldName, Object value) throws Exception {
|
||||
Field field = target.getClass().getDeclaredField(fieldName);
|
||||
field.setAccessible(true);
|
||||
field.set(target, value);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
package com.iflytek.skillhub.controller;
|
||||
|
||||
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
|
||||
import com.iflytek.skillhub.domain.social.SkillRatingService;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
|
@ -107,4 +108,33 @@ class SkillRatingControllerTest {
|
|||
.content("{\"score\": 4}"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
void get_user_rating_missing_skill_returns_404_envelope() throws Exception {
|
||||
PlatformPrincipal principal = new PlatformPrincipal(
|
||||
"user-42",
|
||||
"tester",
|
||||
"tester@example.com",
|
||||
"https://example.com/avatar.png",
|
||||
"github",
|
||||
Set.of("SUPER_ADMIN")
|
||||
);
|
||||
var auth = new UsernamePasswordAuthenticationToken(
|
||||
principal,
|
||||
null,
|
||||
List.of(new SimpleGrantedAuthority("ROLE_SUPER_ADMIN"))
|
||||
);
|
||||
|
||||
when(skillRatingService.getUserRating(eq(999L), eq("user-42")))
|
||||
.thenThrow(new DomainNotFoundException("skill.not_found", 999L));
|
||||
|
||||
mockMvc.perform(get("/api/v1/skills/999/rating")
|
||||
.with(authentication(auth))
|
||||
.with(csrf()))
|
||||
.andExpect(status().isNotFound())
|
||||
.andExpect(jsonPath("$.code").value(404))
|
||||
.andExpect(jsonPath("$.msg").value("Skill not found: 999"))
|
||||
.andExpect(jsonPath("$.timestamp").isNotEmpty())
|
||||
.andExpect(jsonPath("$.requestId").isNotEmpty());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package com.iflytek.skillhub.controller;
|
||||
|
||||
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
|
||||
import com.iflytek.skillhub.domain.social.SkillStarService;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
|
@ -126,4 +127,33 @@ class SkillStarControllerTest {
|
|||
.andExpect(jsonPath("$.timestamp").isNotEmpty())
|
||||
.andExpect(jsonPath("$.requestId").isNotEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void check_starred_missing_skill_returns_404_envelope() throws Exception {
|
||||
PlatformPrincipal principal = new PlatformPrincipal(
|
||||
"user-42",
|
||||
"tester",
|
||||
"tester@example.com",
|
||||
"https://example.com/avatar.png",
|
||||
"github",
|
||||
Set.of("SUPER_ADMIN")
|
||||
);
|
||||
var auth = new UsernamePasswordAuthenticationToken(
|
||||
principal,
|
||||
null,
|
||||
List.of(new SimpleGrantedAuthority("ROLE_SUPER_ADMIN"))
|
||||
);
|
||||
|
||||
when(skillStarService.isStarred(eq(999L), eq("user-42")))
|
||||
.thenThrow(new DomainNotFoundException("skill.not_found", 999L));
|
||||
|
||||
mockMvc.perform(get("/api/v1/skills/999/star")
|
||||
.with(authentication(auth))
|
||||
.with(csrf()))
|
||||
.andExpect(status().isNotFound())
|
||||
.andExpect(jsonPath("$.code").value(404))
|
||||
.andExpect(jsonPath("$.msg").value("Skill not found: 999"))
|
||||
.andExpect(jsonPath("$.timestamp").isNotEmpty())
|
||||
.andExpect(jsonPath("$.requestId").isNotEmpty());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
package com.iflytek.skillhub.auth.device;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.iflytek.skillhub.auth.token.ApiTokenService;
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
|
@ -20,12 +23,25 @@ public class DeviceAuthService {
|
|||
|
||||
private final RedisTemplate<String, Object> redisTemplate;
|
||||
private final String verificationUri;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final ApiTokenService apiTokenService;
|
||||
private final SecureRandom random = new SecureRandom();
|
||||
|
||||
@Autowired
|
||||
public DeviceAuthService(RedisTemplate<String, Object> redisTemplate,
|
||||
ApiTokenService apiTokenService,
|
||||
@Value("${skillhub.device-auth.verification-uri:/device}") String verificationUri) {
|
||||
this(redisTemplate, apiTokenService, verificationUri, new ObjectMapper());
|
||||
}
|
||||
|
||||
public DeviceAuthService(RedisTemplate<String, Object> redisTemplate,
|
||||
ApiTokenService apiTokenService,
|
||||
String verificationUri,
|
||||
ObjectMapper objectMapper) {
|
||||
this.redisTemplate = redisTemplate;
|
||||
this.verificationUri = verificationUri;
|
||||
this.objectMapper = objectMapper;
|
||||
this.apiTokenService = apiTokenService;
|
||||
}
|
||||
|
||||
public DeviceCodeResponse generateDeviceCode() {
|
||||
|
|
@ -48,7 +64,7 @@ public class DeviceAuthService {
|
|||
throw new DomainBadRequestException("error.deviceAuth.userCode.invalid");
|
||||
}
|
||||
|
||||
DeviceCodeData data = (DeviceCodeData) redisTemplate.opsForValue().get(DEVICE_CODE_PREFIX + deviceCode);
|
||||
DeviceCodeData data = readDeviceCodeData(deviceCode);
|
||||
if (data == null) {
|
||||
throw new DomainBadRequestException("error.deviceAuth.deviceCode.expired");
|
||||
}
|
||||
|
|
@ -60,7 +76,7 @@ public class DeviceAuthService {
|
|||
}
|
||||
|
||||
public DeviceTokenResponse pollToken(String deviceCode) {
|
||||
DeviceCodeData data = (DeviceCodeData) redisTemplate.opsForValue().get(DEVICE_CODE_PREFIX + deviceCode);
|
||||
DeviceCodeData data = readDeviceCodeData(deviceCode);
|
||||
|
||||
if (data == null) {
|
||||
throw new DomainBadRequestException("error.deviceAuth.deviceCode.invalid");
|
||||
|
|
@ -72,7 +88,9 @@ public class DeviceAuthService {
|
|||
data.setStatus(DeviceCodeStatus.USED);
|
||||
redisTemplate.opsForValue().set(
|
||||
DEVICE_CODE_PREFIX + deviceCode, data, 1, TimeUnit.MINUTES);
|
||||
yield DeviceTokenResponse.success(null);
|
||||
String token = apiTokenService.createToken(
|
||||
data.getUserId(), "device-auth", "[]").rawToken();
|
||||
yield DeviceTokenResponse.success(token);
|
||||
}
|
||||
case USED -> throw new DomainBadRequestException("error.deviceAuth.deviceCode.used");
|
||||
};
|
||||
|
|
@ -92,4 +110,15 @@ public class DeviceAuthService {
|
|||
}
|
||||
return code.toString();
|
||||
}
|
||||
|
||||
private DeviceCodeData readDeviceCodeData(String deviceCode) {
|
||||
Object raw = redisTemplate.opsForValue().get(DEVICE_CODE_PREFIX + deviceCode);
|
||||
if (raw == null) {
|
||||
return null;
|
||||
}
|
||||
if (raw instanceof DeviceCodeData data) {
|
||||
return data;
|
||||
}
|
||||
return objectMapper.convertValue(raw, DeviceCodeData.class);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
package com.iflytek.skillhub.auth.device;
|
||||
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
|
||||
import com.iflytek.skillhub.auth.token.ApiTokenService;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
|
|
@ -10,6 +12,8 @@ import org.mockito.junit.jupiter.MockitoExtension;
|
|||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.data.redis.core.ValueOperations;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
|
@ -26,12 +30,15 @@ class DeviceAuthServiceTest {
|
|||
@Mock
|
||||
private ValueOperations<String, Object> valueOperations;
|
||||
|
||||
@Mock
|
||||
private ApiTokenService apiTokenService;
|
||||
|
||||
private DeviceAuthService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
when(redisTemplate.opsForValue()).thenReturn(valueOperations);
|
||||
service = new DeviceAuthService(redisTemplate, "https://skillhub.example.com/device");
|
||||
service = new DeviceAuthService(redisTemplate, apiTokenService, "https://skillhub.example.com/device", new ObjectMapper());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -103,4 +110,56 @@ class DeviceAuthServiceTest {
|
|||
assertThat(data.getUserId()).isEqualTo("42");
|
||||
verify(valueOperations).set(eq("device:code:device123"), eq(data), eq(15L), eq(TimeUnit.MINUTES));
|
||||
}
|
||||
|
||||
@Test
|
||||
void authorizeDeviceCode_accepts_linked_hash_map_from_redis_serializer() {
|
||||
Map<String, Object> redisValue = new HashMap<>();
|
||||
redisValue.put("deviceCode", "device123");
|
||||
redisValue.put("userCode", "ABCD-1234");
|
||||
redisValue.put("status", "PENDING");
|
||||
redisValue.put("userId", null);
|
||||
when(valueOperations.get("device:usercode:ABCD-1234")).thenReturn("device123");
|
||||
when(valueOperations.get("device:code:device123")).thenReturn(redisValue);
|
||||
|
||||
service.authorizeDeviceCode("ABCD-1234", "42");
|
||||
|
||||
ArgumentCaptor<DeviceCodeData> captor = ArgumentCaptor.forClass(DeviceCodeData.class);
|
||||
verify(valueOperations).set(eq("device:code:device123"), captor.capture(), eq(15L), eq(TimeUnit.MINUTES));
|
||||
assertThat(captor.getValue().getStatus()).isEqualTo(DeviceCodeStatus.AUTHORIZED);
|
||||
assertThat(captor.getValue().getUserId()).isEqualTo("42");
|
||||
}
|
||||
|
||||
@Test
|
||||
void pollToken_accepts_linked_hash_map_from_redis_serializer() {
|
||||
Map<String, Object> redisValue = new HashMap<>();
|
||||
redisValue.put("deviceCode", "device123");
|
||||
redisValue.put("userCode", "ABCD-1234");
|
||||
redisValue.put("status", "AUTHORIZED");
|
||||
redisValue.put("userId", "42");
|
||||
when(valueOperations.get("device:code:device123")).thenReturn(redisValue);
|
||||
when(apiTokenService.createToken("42", "device-auth", "[]"))
|
||||
.thenReturn(new ApiTokenService.TokenCreateResult("sk_device_token", null));
|
||||
|
||||
DeviceTokenResponse response = service.pollToken("device123");
|
||||
|
||||
assertThat(response.error()).isNull();
|
||||
assertThat(response.accessToken()).isEqualTo("sk_device_token");
|
||||
assertThat(response.tokenType()).isEqualTo("Bearer");
|
||||
verify(valueOperations).set(eq("device:code:device123"), any(DeviceCodeData.class), eq(1L), eq(TimeUnit.MINUTES));
|
||||
}
|
||||
|
||||
@Test
|
||||
void pollToken_returns_access_token_when_authorized() {
|
||||
DeviceCodeData data = new DeviceCodeData("device123", "ABCD-1234", DeviceCodeStatus.AUTHORIZED, "42");
|
||||
when(valueOperations.get("device:code:device123")).thenReturn(data);
|
||||
when(apiTokenService.createToken("42", "device-auth", "[]"))
|
||||
.thenReturn(new ApiTokenService.TokenCreateResult("sk_device_token", null));
|
||||
|
||||
DeviceTokenResponse response = service.pollToken("device123");
|
||||
|
||||
assertThat(response.error()).isNull();
|
||||
assertThat(response.accessToken()).isEqualTo("sk_device_token");
|
||||
assertThat(response.tokenType()).isEqualTo("Bearer");
|
||||
verify(valueOperations).set(eq("device:code:device123"), eq(data), eq(1L), eq(TimeUnit.MINUTES));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,10 +8,12 @@ import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
|
|||
import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException;
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException;
|
||||
import com.iflytek.skillhub.domain.skill.*;
|
||||
import jakarta.persistence.EntityManager;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ConcurrentModificationException;
|
||||
import java.util.List;
|
||||
|
|
@ -27,6 +29,7 @@ public class PromotionService {
|
|||
private final NamespaceRepository namespaceRepository;
|
||||
private final ReviewPermissionChecker permissionChecker;
|
||||
private final ApplicationEventPublisher eventPublisher;
|
||||
private final EntityManager entityManager;
|
||||
|
||||
public PromotionService(PromotionRequestRepository promotionRequestRepository,
|
||||
SkillRepository skillRepository,
|
||||
|
|
@ -34,7 +37,8 @@ public class PromotionService {
|
|||
SkillFileRepository skillFileRepository,
|
||||
NamespaceRepository namespaceRepository,
|
||||
ReviewPermissionChecker permissionChecker,
|
||||
ApplicationEventPublisher eventPublisher) {
|
||||
ApplicationEventPublisher eventPublisher,
|
||||
EntityManager entityManager) {
|
||||
this.promotionRequestRepository = promotionRequestRepository;
|
||||
this.skillRepository = skillRepository;
|
||||
this.skillVersionRepository = skillVersionRepository;
|
||||
|
|
@ -42,6 +46,7 @@ public class PromotionService {
|
|||
this.namespaceRepository = namespaceRepository;
|
||||
this.permissionChecker = permissionChecker;
|
||||
this.eventPublisher = eventPublisher;
|
||||
this.entityManager = entityManager;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
|
|
@ -96,6 +101,11 @@ public class PromotionService {
|
|||
if (updated == 0) {
|
||||
throw new ConcurrentModificationException("Promotion request was modified concurrently");
|
||||
}
|
||||
entityManager.detach(request);
|
||||
request.setStatus(ReviewTaskStatus.APPROVED);
|
||||
request.setReviewedBy(reviewerId);
|
||||
request.setReviewComment(comment);
|
||||
request.setReviewedAt(Instant.now());
|
||||
|
||||
Skill sourceSkill = skillRepository.findById(request.getSourceSkillId())
|
||||
.orElseThrow(() -> new DomainNotFoundException("skill.not_found", request.getSourceSkillId()));
|
||||
|
|
@ -138,9 +148,12 @@ public class PromotionService {
|
|||
.toList();
|
||||
skillFileRepository.saveAll(copiedFiles);
|
||||
|
||||
// Update promotion request with target skill id
|
||||
int targetUpdated = promotionRequestRepository.updateStatusWithVersion(
|
||||
promotionId, ReviewTaskStatus.APPROVED, reviewerId, comment, newSkill.getId(), request.getVersion() + 1);
|
||||
if (targetUpdated == 0) {
|
||||
throw new ConcurrentModificationException("Promotion request target skill was modified concurrently");
|
||||
}
|
||||
request.setTargetSkillId(newSkill.getId());
|
||||
promotionRequestRepository.save(request);
|
||||
|
||||
eventPublisher.publishEvent(new SkillPublishedEvent(
|
||||
newSkill.getId(), newVersion.getId(), reviewerId));
|
||||
|
|
@ -167,7 +180,11 @@ public class PromotionService {
|
|||
if (updated == 0) {
|
||||
throw new ConcurrentModificationException("Promotion request was modified concurrently");
|
||||
}
|
||||
|
||||
return promotionRequestRepository.findById(promotionId).orElse(request);
|
||||
entityManager.detach(request);
|
||||
request.setStatus(ReviewTaskStatus.REJECTED);
|
||||
request.setReviewedBy(reviewerId);
|
||||
request.setReviewComment(comment);
|
||||
request.setReviewedAt(Instant.now());
|
||||
return request;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,10 +30,14 @@ public class ReviewPermissionChecker {
|
|||
return false;
|
||||
}
|
||||
|
||||
if (platformRoles.contains("SKILL_ADMIN")
|
||||
|| platformRoles.contains("SUPER_ADMIN")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Global namespace: only SKILL_ADMIN or SUPER_ADMIN
|
||||
if (namespaceType == NamespaceType.GLOBAL) {
|
||||
return platformRoles.contains("SKILL_ADMIN")
|
||||
|| platformRoles.contains("SUPER_ADMIN");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Team namespace: namespace ADMIN or OWNER
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import com.iflytek.skillhub.domain.skill.SkillRepository;
|
|||
import com.iflytek.skillhub.domain.skill.SkillVersion;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVersionRepository;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVersionStatus;
|
||||
import jakarta.persistence.EntityManager;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
|
@ -32,19 +33,22 @@ public class ReviewService {
|
|||
private final NamespaceRepository namespaceRepository;
|
||||
private final ReviewPermissionChecker permissionChecker;
|
||||
private final ApplicationEventPublisher eventPublisher;
|
||||
private final EntityManager entityManager;
|
||||
|
||||
public ReviewService(ReviewTaskRepository reviewTaskRepository,
|
||||
SkillVersionRepository skillVersionRepository,
|
||||
SkillRepository skillRepository,
|
||||
NamespaceRepository namespaceRepository,
|
||||
ReviewPermissionChecker permissionChecker,
|
||||
ApplicationEventPublisher eventPublisher) {
|
||||
ApplicationEventPublisher eventPublisher,
|
||||
EntityManager entityManager) {
|
||||
this.reviewTaskRepository = reviewTaskRepository;
|
||||
this.skillVersionRepository = skillVersionRepository;
|
||||
this.skillRepository = skillRepository;
|
||||
this.namespaceRepository = namespaceRepository;
|
||||
this.permissionChecker = permissionChecker;
|
||||
this.eventPublisher = eventPublisher;
|
||||
this.entityManager = entityManager;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
|
|
@ -91,6 +95,11 @@ public class ReviewService {
|
|||
if (updated == 0) {
|
||||
throw new ConcurrentModificationException("Review task was modified concurrently");
|
||||
}
|
||||
entityManager.detach(task);
|
||||
task.setStatus(ReviewTaskStatus.APPROVED);
|
||||
task.setReviewedBy(reviewerId);
|
||||
task.setReviewComment(comment);
|
||||
task.setReviewedAt(Instant.now());
|
||||
|
||||
SkillVersion skillVersion = skillVersionRepository.findById(task.getSkillVersionId())
|
||||
.orElseThrow(() -> new DomainNotFoundException("skill_version.not_found", task.getSkillVersionId()));
|
||||
|
|
@ -106,8 +115,7 @@ public class ReviewService {
|
|||
eventPublisher.publishEvent(new SkillPublishedEvent(
|
||||
skill.getId(), skillVersion.getId(), reviewerId));
|
||||
|
||||
// Reload to return updated state
|
||||
return reviewTaskRepository.findById(reviewTaskId).orElse(task);
|
||||
return task;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
|
|
@ -134,13 +142,18 @@ public class ReviewService {
|
|||
if (updated == 0) {
|
||||
throw new ConcurrentModificationException("Review task was modified concurrently");
|
||||
}
|
||||
entityManager.detach(task);
|
||||
task.setStatus(ReviewTaskStatus.REJECTED);
|
||||
task.setReviewedBy(reviewerId);
|
||||
task.setReviewComment(comment);
|
||||
task.setReviewedAt(Instant.now());
|
||||
|
||||
SkillVersion skillVersion = skillVersionRepository.findById(task.getSkillVersionId())
|
||||
.orElseThrow(() -> new DomainNotFoundException("skill_version.not_found", task.getSkillVersionId()));
|
||||
skillVersion.setStatus(SkillVersionStatus.REJECTED);
|
||||
skillVersionRepository.save(skillVersion);
|
||||
|
||||
return reviewTaskRepository.findById(reviewTaskId).orElse(task);
|
||||
return task;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
|
|
|
|||
|
|
@ -214,6 +214,9 @@ public class SkillPublishService {
|
|||
version.setTotalSize(totalSize);
|
||||
skillVersionRepository.save(version);
|
||||
|
||||
ReviewTask reviewTask = new ReviewTask(version.getId(), namespace.getId(), publisherId);
|
||||
reviewTaskRepository.save(reviewTask);
|
||||
|
||||
// 12. Update skill
|
||||
skill.setLatestVersionId(version.getId());
|
||||
skill.setDisplayName(metadata.name());
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
package com.iflytek.skillhub.domain.social;
|
||||
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException;
|
||||
import com.iflytek.skillhub.domain.skill.SkillRepository;
|
||||
import com.iflytek.skillhub.domain.social.event.SkillRatedEvent;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
|
@ -11,16 +13,20 @@ import java.util.Optional;
|
|||
@Service
|
||||
public class SkillRatingService {
|
||||
private final SkillRatingRepository ratingRepository;
|
||||
private final SkillRepository skillRepository;
|
||||
private final ApplicationEventPublisher eventPublisher;
|
||||
|
||||
public SkillRatingService(SkillRatingRepository ratingRepository,
|
||||
SkillRepository skillRepository,
|
||||
ApplicationEventPublisher eventPublisher) {
|
||||
this.ratingRepository = ratingRepository;
|
||||
this.skillRepository = skillRepository;
|
||||
this.eventPublisher = eventPublisher;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void rate(Long skillId, String userId, short score) {
|
||||
ensureSkillExists(skillId);
|
||||
if (score < 1 || score > 5) {
|
||||
throw new DomainBadRequestException("error.rating.score.invalid");
|
||||
}
|
||||
|
|
@ -35,7 +41,14 @@ public class SkillRatingService {
|
|||
}
|
||||
|
||||
public Optional<Short> getUserRating(Long skillId, String userId) {
|
||||
ensureSkillExists(skillId);
|
||||
return ratingRepository.findBySkillIdAndUserId(skillId, userId)
|
||||
.map(SkillRating::getScore);
|
||||
}
|
||||
|
||||
private void ensureSkillExists(Long skillId) {
|
||||
if (skillRepository.findById(skillId).isEmpty()) {
|
||||
throw new DomainNotFoundException("skill.not_found", skillId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
package com.iflytek.skillhub.domain.social;
|
||||
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException;
|
||||
import com.iflytek.skillhub.domain.skill.SkillRepository;
|
||||
import com.iflytek.skillhub.domain.social.event.SkillStarredEvent;
|
||||
import com.iflytek.skillhub.domain.social.event.SkillUnstarredEvent;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
|
|
@ -9,16 +11,20 @@ import org.springframework.transaction.annotation.Transactional;
|
|||
@Service
|
||||
public class SkillStarService {
|
||||
private final SkillStarRepository starRepository;
|
||||
private final SkillRepository skillRepository;
|
||||
private final ApplicationEventPublisher eventPublisher;
|
||||
|
||||
public SkillStarService(SkillStarRepository starRepository,
|
||||
SkillRepository skillRepository,
|
||||
ApplicationEventPublisher eventPublisher) {
|
||||
this.starRepository = starRepository;
|
||||
this.skillRepository = skillRepository;
|
||||
this.eventPublisher = eventPublisher;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void star(Long skillId, String userId) {
|
||||
ensureSkillExists(skillId);
|
||||
if (starRepository.findBySkillIdAndUserId(skillId, userId).isPresent()) {
|
||||
return; // idempotent
|
||||
}
|
||||
|
|
@ -28,6 +34,7 @@ public class SkillStarService {
|
|||
|
||||
@Transactional
|
||||
public void unstar(Long skillId, String userId) {
|
||||
ensureSkillExists(skillId);
|
||||
starRepository.findBySkillIdAndUserId(skillId, userId).ifPresent(star -> {
|
||||
starRepository.delete(star);
|
||||
eventPublisher.publishEvent(new SkillUnstarredEvent(skillId, userId));
|
||||
|
|
@ -35,6 +42,13 @@ public class SkillStarService {
|
|||
}
|
||||
|
||||
public boolean isStarred(Long skillId, String userId) {
|
||||
ensureSkillExists(skillId);
|
||||
return starRepository.findBySkillIdAndUserId(skillId, userId).isPresent();
|
||||
}
|
||||
|
||||
private void ensureSkillExists(Long skillId) {
|
||||
if (skillRepository.findById(skillId).isEmpty()) {
|
||||
throw new DomainNotFoundException("skill.not_found", skillId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import org.mockito.ArgumentCaptor;
|
|||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import jakarta.persistence.EntityManager;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
|
|
@ -34,6 +35,7 @@ class PromotionServiceTest {
|
|||
@Mock private NamespaceRepository namespaceRepository;
|
||||
@Mock private ReviewPermissionChecker permissionChecker;
|
||||
@Mock private ApplicationEventPublisher eventPublisher;
|
||||
@Mock private EntityManager entityManager;
|
||||
|
||||
private PromotionService promotionService;
|
||||
|
||||
|
|
@ -50,7 +52,7 @@ class PromotionServiceTest {
|
|||
void setUp() {
|
||||
promotionService = new PromotionService(
|
||||
promotionRequestRepository, skillRepository, skillVersionRepository,
|
||||
skillFileRepository, namespaceRepository, permissionChecker, eventPublisher);
|
||||
skillFileRepository, namespaceRepository, permissionChecker, eventPublisher, entityManager);
|
||||
}
|
||||
|
||||
private static void setField(Object target, String fieldName, Object value) {
|
||||
|
|
@ -233,6 +235,9 @@ class PromotionServiceTest {
|
|||
when(promotionRequestRepository.updateStatusWithVersion(
|
||||
PROMOTION_ID, ReviewTaskStatus.APPROVED, REVIEWER_ID, "LGTM", null, pr.getVersion()))
|
||||
.thenReturn(1);
|
||||
when(promotionRequestRepository.updateStatusWithVersion(
|
||||
PROMOTION_ID, ReviewTaskStatus.APPROVED, REVIEWER_ID, "LGTM", NEW_SKILL_ID, pr.getVersion() + 1))
|
||||
.thenReturn(1);
|
||||
when(skillRepository.findById(SOURCE_SKILL_ID)).thenReturn(Optional.of(sourceSkill));
|
||||
when(skillVersionRepository.findById(SOURCE_VERSION_ID)).thenReturn(Optional.of(sourceVersion));
|
||||
when(skillRepository.save(any(Skill.class))).thenAnswer(inv -> {
|
||||
|
|
@ -292,8 +297,9 @@ class PromotionServiceTest {
|
|||
assertEquals(NEW_VERSION_ID, event.versionId());
|
||||
assertEquals(REVIEWER_ID, event.publisherId());
|
||||
|
||||
// Verify targetSkillId updated on promotion request
|
||||
verify(promotionRequestRepository).save(pr);
|
||||
verify(entityManager).detach(pr);
|
||||
verify(promotionRequestRepository).updateStatusWithVersion(
|
||||
PROMOTION_ID, ReviewTaskStatus.APPROVED, REVIEWER_ID, "LGTM", NEW_SKILL_ID, pr.getVersion() + 1);
|
||||
assertEquals(NEW_SKILL_ID, pr.getTargetSkillId());
|
||||
}
|
||||
|
||||
|
|
@ -346,6 +352,9 @@ class PromotionServiceTest {
|
|||
when(promotionRequestRepository.findById(PROMOTION_ID)).thenReturn(Optional.of(pr));
|
||||
when(permissionChecker.canReviewPromotion(pr, REVIEWER_ID, Set.of("SKILL_ADMIN"))).thenReturn(true);
|
||||
when(promotionRequestRepository.updateStatusWithVersion(any(), any(), any(), any(), any(), any())).thenReturn(1);
|
||||
when(promotionRequestRepository.updateStatusWithVersion(
|
||||
PROMOTION_ID, ReviewTaskStatus.APPROVED, REVIEWER_ID, "ok", NEW_SKILL_ID, pr.getVersion() + 1))
|
||||
.thenReturn(1);
|
||||
when(skillRepository.findById(SOURCE_SKILL_ID)).thenReturn(Optional.of(sourceSkill));
|
||||
when(skillVersionRepository.findById(SOURCE_VERSION_ID)).thenReturn(Optional.of(sourceVersion));
|
||||
when(skillRepository.save(any(Skill.class))).thenAnswer(inv -> {
|
||||
|
|
@ -382,14 +391,15 @@ class PromotionServiceTest {
|
|||
when(promotionRequestRepository.updateStatusWithVersion(
|
||||
PROMOTION_ID, ReviewTaskStatus.REJECTED, REVIEWER_ID, "Not ready", null, pr.getVersion()))
|
||||
.thenReturn(1);
|
||||
when(promotionRequestRepository.findById(PROMOTION_ID)).thenReturn(Optional.of(pr));
|
||||
|
||||
PromotionRequest result = promotionService.rejectPromotion(
|
||||
PROMOTION_ID, REVIEWER_ID, "Not ready", Set.of("SKILL_ADMIN"));
|
||||
|
||||
assertNotNull(result);
|
||||
assertEquals(ReviewTaskStatus.REJECTED, result.getStatus());
|
||||
assertEquals(REVIEWER_ID, result.getReviewedBy());
|
||||
verify(promotionRequestRepository).updateStatusWithVersion(
|
||||
PROMOTION_ID, ReviewTaskStatus.REJECTED, REVIEWER_ID, "Not ready", null, pr.getVersion());
|
||||
verify(entityManager).detach(pr);
|
||||
verify(eventPublisher, never()).publishEvent(any());
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -66,11 +66,19 @@ class ReviewPermissionCheckerTest {
|
|||
@Test
|
||||
void skillAdminCannotReviewTeamSkill() {
|
||||
ReviewTask task = new ReviewTask(1L, 10L, "user-2");
|
||||
assertFalse(checker.canReview(task, "user-1",
|
||||
assertTrue(checker.canReview(task, "user-1",
|
||||
NamespaceType.TEAM,
|
||||
Map.of(), Set.of("SKILL_ADMIN")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void superAdminCanReviewTeamSkill() {
|
||||
ReviewTask task = new ReviewTask(1L, 10L, "user-2");
|
||||
assertTrue(checker.canReview(task, "user-1",
|
||||
NamespaceType.TEAM,
|
||||
Map.of(), Set.of("SUPER_ADMIN")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void nonAdminCannotReviewGlobalSkill() {
|
||||
ReviewTask task = new ReviewTask(1L, 1L, "user-2");
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import com.iflytek.skillhub.domain.skill.SkillVersion;
|
|||
import com.iflytek.skillhub.domain.skill.SkillVersionRepository;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVersionStatus;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVisibility;
|
||||
import jakarta.persistence.EntityManager;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
|
@ -41,6 +42,7 @@ class ReviewServiceTest {
|
|||
@Mock private NamespaceRepository namespaceRepository;
|
||||
@Mock private ReviewPermissionChecker permissionChecker;
|
||||
@Mock private ApplicationEventPublisher eventPublisher;
|
||||
@Mock private EntityManager entityManager;
|
||||
|
||||
private ReviewService reviewService;
|
||||
|
||||
|
|
@ -55,7 +57,7 @@ class ReviewServiceTest {
|
|||
void setUp() {
|
||||
reviewService = new ReviewService(
|
||||
reviewTaskRepository, skillVersionRepository, skillRepository,
|
||||
namespaceRepository, permissionChecker, eventPublisher);
|
||||
namespaceRepository, permissionChecker, eventPublisher, entityManager);
|
||||
}
|
||||
|
||||
private SkillVersion createDraftSkillVersion() {
|
||||
|
|
@ -164,13 +166,16 @@ class ReviewServiceTest {
|
|||
.thenReturn(1);
|
||||
when(skillVersionRepository.findById(SKILL_VERSION_ID)).thenReturn(Optional.of(sv));
|
||||
when(skillRepository.findById(SKILL_ID)).thenReturn(Optional.of(skill));
|
||||
when(reviewTaskRepository.findById(REVIEW_TASK_ID)).thenReturn(Optional.of(task));
|
||||
|
||||
ReviewTask result = reviewService.approveReview(
|
||||
REVIEW_TASK_ID, REVIEWER_ID, "LGTM",
|
||||
Map.of(NAMESPACE_ID, NamespaceRole.ADMIN), Set.of());
|
||||
|
||||
assertNotNull(result);
|
||||
assertEquals(ReviewTaskStatus.APPROVED, result.getStatus());
|
||||
assertEquals(REVIEWER_ID, result.getReviewedBy());
|
||||
assertEquals("LGTM", result.getReviewComment());
|
||||
assertNotNull(result.getReviewedAt());
|
||||
assertEquals(SkillVersionStatus.PUBLISHED, sv.getStatus());
|
||||
assertNotNull(sv.getPublishedAt());
|
||||
assertEquals(SKILL_VERSION_ID, skill.getLatestVersionId());
|
||||
|
|
@ -190,7 +195,6 @@ class ReviewServiceTest {
|
|||
when(reviewTaskRepository.updateStatusWithVersion(any(), any(), any(), any(), any())).thenReturn(1);
|
||||
when(skillVersionRepository.findById(SKILL_VERSION_ID)).thenReturn(Optional.of(sv));
|
||||
when(skillRepository.findById(SKILL_ID)).thenReturn(Optional.of(skill));
|
||||
when(reviewTaskRepository.findById(REVIEW_TASK_ID)).thenReturn(Optional.of(task));
|
||||
|
||||
reviewService.approveReview(REVIEW_TASK_ID, REVIEWER_ID, "ok",
|
||||
Map.of(NAMESPACE_ID, NamespaceRole.ADMIN), Set.of());
|
||||
|
|
@ -262,13 +266,16 @@ class ReviewServiceTest {
|
|||
when(permissionChecker.canReview(any(), any(), any(), anyMap(), anySet())).thenReturn(true);
|
||||
when(reviewTaskRepository.updateStatusWithVersion(any(), any(), any(), any(), any())).thenReturn(1);
|
||||
when(skillVersionRepository.findById(SKILL_VERSION_ID)).thenReturn(Optional.of(sv));
|
||||
when(reviewTaskRepository.findById(REVIEW_TASK_ID)).thenReturn(Optional.of(task));
|
||||
|
||||
ReviewTask result = reviewService.rejectReview(
|
||||
REVIEW_TASK_ID, REVIEWER_ID, "needs work",
|
||||
Map.of(NAMESPACE_ID, NamespaceRole.ADMIN), Set.of());
|
||||
|
||||
assertNotNull(result);
|
||||
assertEquals(ReviewTaskStatus.REJECTED, result.getStatus());
|
||||
assertEquals(REVIEWER_ID, result.getReviewedBy());
|
||||
assertEquals("needs work", result.getReviewComment());
|
||||
assertNotNull(result.getReviewedAt());
|
||||
assertEquals(SkillVersionStatus.REJECTED, sv.getStatus());
|
||||
verify(skillVersionRepository).save(sv);
|
||||
verify(eventPublisher, never()).publishEvent(any(SkillPublishedEvent.class));
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import com.iflytek.skillhub.domain.namespace.Namespace;
|
|||
import com.iflytek.skillhub.domain.namespace.NamespaceMember;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
|
||||
import com.iflytek.skillhub.domain.review.ReviewTask;
|
||||
import com.iflytek.skillhub.domain.review.ReviewTaskRepository;
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
|
||||
import com.iflytek.skillhub.domain.skill.*;
|
||||
|
|
@ -128,6 +129,7 @@ class SkillPublishServiceTest {
|
|||
verify(eventPublisher).publishEvent(any(SkillPublishedEvent.class));
|
||||
verify(skillFileRepository).saveAll(anyList());
|
||||
verify(objectStorageService, atLeastOnce()).putObject(anyString(), any(), anyLong(), anyString());
|
||||
verify(reviewTaskRepository).save(any(ReviewTask.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -168,6 +170,7 @@ class SkillPublishServiceTest {
|
|||
|
||||
assertEquals("smoke-skill-two", result.slug());
|
||||
verify(skillRepository).findByNamespaceIdAndSlug(1L, "smoke-skill-two");
|
||||
verify(reviewTaskRepository).save(any(ReviewTask.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
package com.iflytek.skillhub.domain.social;
|
||||
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException;
|
||||
import com.iflytek.skillhub.domain.skill.Skill;
|
||||
import com.iflytek.skillhub.domain.skill.SkillRepository;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVisibility;
|
||||
import com.iflytek.skillhub.domain.social.event.SkillRatedEvent;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
|
|
@ -16,11 +20,17 @@ import static org.mockito.Mockito.*;
|
|||
@ExtendWith(MockitoExtension.class)
|
||||
class SkillRatingServiceTest {
|
||||
@Mock SkillRatingRepository ratingRepository;
|
||||
@Mock SkillRepository skillRepository;
|
||||
@Mock ApplicationEventPublisher eventPublisher;
|
||||
@InjectMocks SkillRatingService service;
|
||||
|
||||
private Skill skill() {
|
||||
return new Skill(1L, "skill-1", "owner-1", SkillVisibility.PUBLIC);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rate_creates_new_rating() {
|
||||
when(skillRepository.findById(1L)).thenReturn(Optional.of(skill()));
|
||||
when(ratingRepository.findBySkillIdAndUserId(1L, "10")).thenReturn(Optional.empty());
|
||||
when(ratingRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
|
|
@ -32,6 +42,7 @@ class SkillRatingServiceTest {
|
|||
|
||||
@Test
|
||||
void rate_updates_existing_rating() {
|
||||
when(skillRepository.findById(1L)).thenReturn(Optional.of(skill()));
|
||||
SkillRating existing = new SkillRating(1L, "10", (short) 3);
|
||||
when(ratingRepository.findBySkillIdAndUserId(1L, "10")).thenReturn(Optional.of(existing));
|
||||
when(ratingRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
|
@ -45,6 +56,7 @@ class SkillRatingServiceTest {
|
|||
|
||||
@Test
|
||||
void rate_invalid_score_throws() {
|
||||
when(skillRepository.findById(1L)).thenReturn(Optional.of(skill()));
|
||||
assertThatThrownBy(() -> service.rate(1L, "10", (short) 0))
|
||||
.isInstanceOf(DomainBadRequestException.class);
|
||||
assertThatThrownBy(() -> service.rate(1L, "10", (short) 6))
|
||||
|
|
@ -53,8 +65,16 @@ class SkillRatingServiceTest {
|
|||
|
||||
@Test
|
||||
void getUserRating_returns_score() {
|
||||
when(skillRepository.findById(1L)).thenReturn(Optional.of(skill()));
|
||||
SkillRating existing = new SkillRating(1L, "10", (short) 4);
|
||||
when(ratingRepository.findBySkillIdAndUserId(1L, "10")).thenReturn(Optional.of(existing));
|
||||
assertThat(service.getUserRating(1L, "10")).hasValue((short) 4);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getUserRating_throws_when_skill_missing() {
|
||||
when(skillRepository.findById(99L)).thenReturn(Optional.empty());
|
||||
assertThatThrownBy(() -> service.getUserRating(99L, "10"))
|
||||
.isInstanceOf(DomainNotFoundException.class);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
package com.iflytek.skillhub.domain.social;
|
||||
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException;
|
||||
import com.iflytek.skillhub.domain.skill.Skill;
|
||||
import com.iflytek.skillhub.domain.skill.SkillRepository;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVisibility;
|
||||
import com.iflytek.skillhub.domain.social.event.SkillStarredEvent;
|
||||
import com.iflytek.skillhub.domain.social.event.SkillUnstarredEvent;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
|
@ -16,11 +20,17 @@ import static org.mockito.Mockito.*;
|
|||
@ExtendWith(MockitoExtension.class)
|
||||
class SkillStarServiceTest {
|
||||
@Mock SkillStarRepository starRepository;
|
||||
@Mock SkillRepository skillRepository;
|
||||
@Mock ApplicationEventPublisher eventPublisher;
|
||||
@InjectMocks SkillStarService service;
|
||||
|
||||
private Skill skill() {
|
||||
return new Skill(1L, "skill-1", "owner-1", SkillVisibility.PUBLIC);
|
||||
}
|
||||
|
||||
@Test
|
||||
void star_skill_creates_record_and_publishes_event() {
|
||||
when(skillRepository.findById(1L)).thenReturn(Optional.of(skill()));
|
||||
when(starRepository.findBySkillIdAndUserId(1L, "10")).thenReturn(Optional.empty());
|
||||
when(starRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
|
|
@ -32,6 +42,7 @@ class SkillStarServiceTest {
|
|||
|
||||
@Test
|
||||
void star_skill_already_starred_is_idempotent() {
|
||||
when(skillRepository.findById(1L)).thenReturn(Optional.of(skill()));
|
||||
when(starRepository.findBySkillIdAndUserId(1L, "10"))
|
||||
.thenReturn(Optional.of(new SkillStar(1L, "10")));
|
||||
|
||||
|
|
@ -43,6 +54,7 @@ class SkillStarServiceTest {
|
|||
|
||||
@Test
|
||||
void unstar_skill_deletes_record_and_publishes_event() {
|
||||
when(skillRepository.findById(1L)).thenReturn(Optional.of(skill()));
|
||||
SkillStar existing = new SkillStar(1L, "10");
|
||||
when(starRepository.findBySkillIdAndUserId(1L, "10")).thenReturn(Optional.of(existing));
|
||||
|
||||
|
|
@ -54,6 +66,7 @@ class SkillStarServiceTest {
|
|||
|
||||
@Test
|
||||
void unstar_skill_not_starred_is_noop() {
|
||||
when(skillRepository.findById(1L)).thenReturn(Optional.of(skill()));
|
||||
when(starRepository.findBySkillIdAndUserId(1L, "10")).thenReturn(Optional.empty());
|
||||
|
||||
service.unstar(1L, "10");
|
||||
|
|
@ -64,8 +77,16 @@ class SkillStarServiceTest {
|
|||
|
||||
@Test
|
||||
void isStarred_returns_true_when_exists() {
|
||||
when(skillRepository.findById(1L)).thenReturn(Optional.of(skill()));
|
||||
when(starRepository.findBySkillIdAndUserId(1L, "10"))
|
||||
.thenReturn(Optional.of(new SkillStar(1L, "10")));
|
||||
assertThat(service.isStarred(1L, "10")).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void star_skill_throws_when_skill_missing() {
|
||||
when(skillRepository.findById(99L)).thenReturn(Optional.empty());
|
||||
assertThatThrownBy(() -> service.star(99L, "10"))
|
||||
.isInstanceOf(DomainNotFoundException.class);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,9 @@
|
|||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>SkillHub</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300..700;1,9..40,300..700&family=Outfit:wght@300;400;500;600;700&family=Playfair+Display:ital,wght@0,400..900;1,400..900&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
|
|
@ -5,31 +5,51 @@ export function Layout() {
|
|||
const { user, isLoading } = useAuth()
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<header className="border-b">
|
||||
<div className="container mx-auto flex h-14 items-center justify-between px-4">
|
||||
<Link to="/" className="text-lg font-semibold hover:text-primary">
|
||||
SkillHub
|
||||
<div className="min-h-screen bg-background bg-dots relative">
|
||||
{/* Glow orbs */}
|
||||
<div className="glow-orb-primary" style={{ top: '-10%', right: '10%' }} />
|
||||
<div className="glow-orb-accent" style={{ bottom: '20%', left: '-5%' }} />
|
||||
|
||||
{/* Glass header */}
|
||||
<header className="sticky top-0 z-50 glass-strong border-b border-border/40">
|
||||
<div className="container mx-auto flex h-16 items-center justify-between px-4 lg:px-8">
|
||||
<Link to="/" className="flex items-center gap-2 group">
|
||||
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-primary to-primary/70 flex items-center justify-center shadow-glow">
|
||||
<span className="text-primary-foreground font-bold text-sm">S</span>
|
||||
</div>
|
||||
<span className="text-xl font-bold font-heading text-foreground group-hover:text-primary transition-colors">
|
||||
SkillHub
|
||||
</span>
|
||||
</Link>
|
||||
<nav className="flex items-center gap-4">
|
||||
|
||||
<nav className="flex items-center gap-6">
|
||||
{isLoading ? null : user ? (
|
||||
<>
|
||||
<Link
|
||||
to="/dashboard"
|
||||
className="text-sm hover:text-primary"
|
||||
activeProps={{ className: 'text-primary font-medium' }}
|
||||
className="text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
|
||||
activeProps={{ className: 'text-primary' }}
|
||||
>
|
||||
Dashboard
|
||||
</Link>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{user.displayName}
|
||||
</span>
|
||||
<div className="flex items-center gap-3">
|
||||
{user.avatarUrl && (
|
||||
<img
|
||||
src={user.avatarUrl}
|
||||
alt={user.displayName}
|
||||
className="w-8 h-8 rounded-full border border-border/60"
|
||||
/>
|
||||
)}
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{user.displayName}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<Link
|
||||
to="/login"
|
||||
className="text-sm hover:text-primary"
|
||||
activeProps={{ className: 'text-primary font-medium' }}
|
||||
className="text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
|
||||
activeProps={{ className: 'text-primary' }}
|
||||
>
|
||||
登录
|
||||
</Link>
|
||||
|
|
@ -37,9 +57,89 @@ export function Layout() {
|
|||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
<main className="container mx-auto px-4 py-8">
|
||||
|
||||
<main className="container mx-auto px-4 lg:px-8 py-12 relative z-10">
|
||||
<Outlet />
|
||||
</main>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="relative z-10 border-t border-border/40 bg-card/30 backdrop-blur-sm mt-24">
|
||||
<div className="container mx-auto px-4 lg:px-8 py-12">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-8 mb-8">
|
||||
<div className="col-span-1 md:col-span-2">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-primary to-primary/70 flex items-center justify-center shadow-glow">
|
||||
<span className="text-primary-foreground font-bold text-sm">S</span>
|
||||
</div>
|
||||
<span className="text-xl font-bold font-heading text-foreground">SkillHub</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground max-w-sm">
|
||||
现代化的技能注册中心,为开发者提供高效的技能管理和分发平台。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold font-heading text-foreground mb-3">快速链接</h3>
|
||||
<ul className="space-y-2">
|
||||
<li>
|
||||
<Link to="/" className="text-sm text-muted-foreground hover:text-primary transition-colors">
|
||||
首页
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
to="/search"
|
||||
search={{ q: '', sort: 'relevance', page: 1 }}
|
||||
className="text-sm text-muted-foreground hover:text-primary transition-colors"
|
||||
>
|
||||
搜索技能
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to="/dashboard" className="text-sm text-muted-foreground hover:text-primary transition-colors">
|
||||
Dashboard
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold font-heading text-foreground mb-3">资源</h3>
|
||||
<ul className="space-y-2">
|
||||
<li>
|
||||
<a href="#" className="text-sm text-muted-foreground hover:text-primary transition-colors">
|
||||
文档
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#" className="text-sm text-muted-foreground hover:text-primary transition-colors">
|
||||
API
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#" className="text-sm text-muted-foreground hover:text-primary transition-colors">
|
||||
社区
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-6 border-t border-border/40 flex flex-col md:flex-row items-center justify-between gap-4">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
© 2024 SkillHub. All rights reserved.
|
||||
</p>
|
||||
<div className="flex items-center gap-4">
|
||||
<a href="#" className="text-xs text-muted-foreground hover:text-primary transition-colors">
|
||||
隐私政策
|
||||
</a>
|
||||
<a href="#" className="text-xs text-muted-foreground hover:text-primary transition-colors">
|
||||
服务条款
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,8 @@ export function LoginButton() {
|
|||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Button className="w-full" disabled>
|
||||
<Button className="w-full h-12" disabled>
|
||||
<div className="w-5 h-5 rounded-full animate-shimmer mr-3" />
|
||||
加载中...
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -26,11 +27,15 @@ export function LoginButton() {
|
|||
{providers.map((provider) => (
|
||||
<Button
|
||||
key={provider.id}
|
||||
className="w-full"
|
||||
className="w-full h-12 text-base"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
window.location.href = provider.authorizationUrl
|
||||
}}
|
||||
>
|
||||
<svg className="w-5 h-5 mr-3" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
|
||||
</svg>
|
||||
使用 {provider.name} 登录
|
||||
</Button>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -30,32 +30,37 @@ export function UploadZone({ onFileSelect, disabled }: UploadZoneProps) {
|
|||
<div
|
||||
{...getRootProps()}
|
||||
className={cn(
|
||||
'border-2 border-dashed rounded-lg p-8 text-center cursor-pointer transition-colors',
|
||||
isDragActive && 'border-primary bg-primary/5',
|
||||
!isDragActive && 'border-muted-foreground/25 hover:border-primary/50',
|
||||
'border-2 border-dashed rounded-xl p-10 text-center cursor-pointer transition-all duration-300',
|
||||
isDragActive && 'border-primary bg-primary/5 scale-[1.01]',
|
||||
!isDragActive && 'border-border/60 hover:border-primary/40 hover:bg-secondary/30',
|
||||
disabled && 'opacity-50 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<svg
|
||||
className="w-12 h-12 text-muted-foreground"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
|
||||
/>
|
||||
</svg>
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="w-14 h-14 rounded-2xl bg-secondary/60 flex items-center justify-center">
|
||||
<svg
|
||||
className={cn(
|
||||
'w-7 h-7 transition-colors',
|
||||
isDragActive ? 'text-primary' : 'text-muted-foreground'
|
||||
)}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
{isDragActive ? (
|
||||
<p className="text-sm text-muted-foreground">放开以上传文件...</p>
|
||||
<p className="text-sm text-primary font-medium">放开以上传文件...</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm font-medium">拖拽 ZIP 文件到此处,或点击选择</p>
|
||||
<p className="text-sm font-medium text-foreground">拖拽 ZIP 文件到此处,或点击选择</p>
|
||||
<p className="text-xs text-muted-foreground">仅支持 .zip 格式</p>
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useState, type FormEvent } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
|
||||
|
|
@ -11,7 +11,7 @@ interface SearchBarProps {
|
|||
export function SearchBar({ defaultValue = '', placeholder = '搜索技能...', onSearch }: SearchBarProps) {
|
||||
const [query, setQuery] = useState(defaultValue)
|
||||
|
||||
const handleSubmit = (e: FormEvent) => {
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (onSearch) {
|
||||
onSearch(query)
|
||||
|
|
@ -19,15 +19,32 @@ export function SearchBar({ defaultValue = '', placeholder = '搜索技能...',
|
|||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="flex gap-2">
|
||||
<Input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button type="submit">搜索</Button>
|
||||
<form onSubmit={handleSubmit} className="flex gap-3 glass-strong p-2 rounded-xl">
|
||||
<div className="relative flex-1">
|
||||
<svg
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-muted-foreground pointer-events-none"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
<Input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className="pl-10 border-0 bg-transparent focus-visible:ring-0 focus-visible:ring-offset-0 h-12"
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" size="lg" className="px-8">
|
||||
搜索
|
||||
</Button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,28 +10,47 @@ interface SkillCardProps {
|
|||
export function SkillCard({ skill, onClick }: SkillCardProps) {
|
||||
return (
|
||||
<Card
|
||||
className="p-4 hover:shadow-md transition-shadow cursor-pointer"
|
||||
className="p-5 cursor-pointer group relative overflow-hidden"
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<h3 className="font-semibold text-lg">{skill.displayName}</h3>
|
||||
<NamespaceBadge type="TEAM" name={`@${skill.namespace}`} />
|
||||
</div>
|
||||
{/* Hover gradient border effect */}
|
||||
<div className="absolute inset-0 rounded-xl opacity-0 group-hover:opacity-100 transition-opacity duration-300 bg-gradient-to-br from-primary/20 via-transparent to-accent/20 pointer-events-none" />
|
||||
|
||||
{skill.summary && (
|
||||
<p className="text-sm text-muted-foreground mb-3 line-clamp-2">
|
||||
{skill.summary}
|
||||
</p>
|
||||
)}
|
||||
<div className="relative z-10">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<h3 className="font-semibold font-heading text-lg text-foreground group-hover:text-primary transition-colors">
|
||||
{skill.displayName}
|
||||
</h3>
|
||||
<NamespaceBadge type="TEAM" name={`@${skill.namespace}`} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
{skill.latestVersion && (
|
||||
<span>v{skill.latestVersion}</span>
|
||||
)}
|
||||
<span>{skill.downloadCount} 下载</span>
|
||||
{skill.ratingAvg !== undefined && skill.ratingCount > 0 && (
|
||||
<span>⭐ {skill.ratingAvg.toFixed(1)} ({skill.ratingCount})</span>
|
||||
{skill.summary && (
|
||||
<p className="text-sm text-muted-foreground mb-4 line-clamp-2 leading-relaxed">
|
||||
{skill.summary}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
{skill.latestVersion && (
|
||||
<span className="px-2.5 py-1 rounded-full bg-secondary/60 font-mono">
|
||||
v{skill.latestVersion}
|
||||
</span>
|
||||
)}
|
||||
<span className="flex items-center gap-1">
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M9 19l3 3m0 0l3-3m-3 3V10" />
|
||||
</svg>
|
||||
{skill.downloadCount}
|
||||
</span>
|
||||
{skill.ratingAvg !== undefined && skill.ratingCount > 0 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<svg className="w-3.5 h-3.5 text-primary" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
|
||||
</svg>
|
||||
{skill.ratingAvg.toFixed(1)} ({skill.ratingCount})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -4,48 +4,63 @@
|
|||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 222.2 84% 4.9%;
|
||||
/* Aurora Tech — light theme (default) */
|
||||
--background: 210 40% 97%;
|
||||
--foreground: 222 47% 11%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 222.2 84% 4.9%;
|
||||
--card-foreground: 222 47% 11%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 222.2 84% 4.9%;
|
||||
--primary: 222.2 47.4% 11.2%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--secondary: 210 40% 96.1%;
|
||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||
--muted: 210 40% 96.1%;
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
--accent: 210 40% 96.1%;
|
||||
--accent-foreground: 222.2 47.4% 11.2%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 214.3 31.8% 91.4%;
|
||||
--input: 214.3 31.8% 91.4%;
|
||||
--ring: 222.2 84% 4.9%;
|
||||
--radius: 0.5rem;
|
||||
--popover-foreground: 222 47% 11%;
|
||||
/* Cyan primary */
|
||||
--primary: 192 80% 42%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
/* Surface tones */
|
||||
--secondary: 210 30% 93%;
|
||||
--secondary-foreground: 222 47% 11%;
|
||||
--muted: 210 25% 92%;
|
||||
--muted-foreground: 215 16% 42%;
|
||||
/* Violet accent */
|
||||
--accent: 263 60% 58%;
|
||||
--accent-foreground: 0 0% 100%;
|
||||
--destructive: 0 72% 55%;
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
--border: 214 20% 88%;
|
||||
--input: 214 20% 88%;
|
||||
--ring: 192 80% 42%;
|
||||
--radius: 0.75rem;
|
||||
|
||||
/* Extended palette */
|
||||
--surface-glass: 210 30% 95%;
|
||||
--glow-primary: 192 80% 42%;
|
||||
--glow-accent: 263 60% 58%;
|
||||
--success: 160 60% 45%;
|
||||
--warning: 38 92% 58%;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 222.2 84% 4.9%;
|
||||
--foreground: 210 40% 98%;
|
||||
--card: 222.2 84% 4.9%;
|
||||
--card-foreground: 210 40% 98%;
|
||||
--popover: 222.2 84% 4.9%;
|
||||
--popover-foreground: 210 40% 98%;
|
||||
--primary: 210 40% 98%;
|
||||
--primary-foreground: 222.2 47.4% 11.2%;
|
||||
--secondary: 217.2 32.6% 17.5%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
--muted: 217.2 32.6% 17.5%;
|
||||
--muted-foreground: 215 20.2% 65.1%;
|
||||
--accent: 217.2 32.6% 17.5%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 217.2 32.6% 17.5%;
|
||||
--input: 217.2 32.6% 17.5%;
|
||||
--ring: 212.7 26.8% 83.9%;
|
||||
/* Aurora Tech — deep navy dark theme */
|
||||
--background: 222 47% 6%;
|
||||
--foreground: 210 40% 96%;
|
||||
--card: 222 40% 9%;
|
||||
--card-foreground: 210 40% 96%;
|
||||
--popover: 222 40% 9%;
|
||||
--popover-foreground: 210 40% 96%;
|
||||
--primary: 192 91% 56%;
|
||||
--primary-foreground: 222 47% 6%;
|
||||
--secondary: 222 30% 13%;
|
||||
--secondary-foreground: 210 30% 85%;
|
||||
--muted: 222 25% 15%;
|
||||
--muted-foreground: 215 20% 55%;
|
||||
--accent: 263 70% 70%;
|
||||
--accent-foreground: 0 0% 100%;
|
||||
--destructive: 0 72% 55%;
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
--border: 222 20% 18%;
|
||||
--input: 222 20% 18%;
|
||||
--ring: 192 91% 56%;
|
||||
--surface-glass: 222 35% 11%;
|
||||
--glow-primary: 192 91% 56%;
|
||||
--glow-accent: 263 70% 70%;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -53,7 +68,207 @@
|
|||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
@apply bg-background text-foreground antialiased;
|
||||
font-family: 'DM Sans', system-ui, sans-serif;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-family: 'Outfit', 'DM Sans', system-ui, sans-serif;
|
||||
}
|
||||
|
||||
code, pre, kbd {
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── Dot-grid background texture ─── */
|
||||
.bg-dots {
|
||||
background-image: radial-gradient(circle, hsl(var(--muted-foreground) / 0.12) 1px, transparent 1px);
|
||||
background-size: 24px 24px;
|
||||
}
|
||||
|
||||
/* ─── Gradient glow orbs ─── */
|
||||
.glow-orb-primary {
|
||||
position: fixed;
|
||||
width: 600px;
|
||||
height: 600px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, hsl(var(--glow-primary) / 0.08) 0%, transparent 70%);
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.glow-orb-accent {
|
||||
position: fixed;
|
||||
width: 500px;
|
||||
height: 500px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, hsl(var(--glow-accent) / 0.06) 0%, transparent 70%);
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
/* ─── Glass morphism ─── */
|
||||
.glass {
|
||||
background: hsl(var(--surface-glass) / 0.7);
|
||||
backdrop-filter: blur(16px) saturate(1.4);
|
||||
-webkit-backdrop-filter: blur(16px) saturate(1.4);
|
||||
border: 1px solid hsl(var(--border) / 0.5);
|
||||
}
|
||||
|
||||
.glass-strong {
|
||||
background: hsl(var(--surface-glass) / 0.85);
|
||||
backdrop-filter: blur(24px) saturate(1.6);
|
||||
-webkit-backdrop-filter: blur(24px) saturate(1.6);
|
||||
border: 1px solid hsl(var(--border) / 0.6);
|
||||
}
|
||||
|
||||
/* ─── Animations ─── */
|
||||
@keyframes fade-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes slide-in-right {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(16px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { background-position: -200% 0; }
|
||||
100% { background-position: 200% 0; }
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-8px); }
|
||||
}
|
||||
|
||||
@keyframes pulse-glow {
|
||||
0%, 100% { opacity: 0.6; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
|
||||
.animate-fade-up {
|
||||
animation: fade-up 0.6s cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fade-in 0.5s ease both;
|
||||
}
|
||||
|
||||
.animate-slide-in-right {
|
||||
animation: slide-in-right 0.5s cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
}
|
||||
|
||||
.animate-shimmer {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
hsl(var(--muted)) 25%,
|
||||
hsl(var(--muted-foreground) / 0.08) 50%,
|
||||
hsl(var(--muted)) 75%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.animate-float {
|
||||
animation: float 6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Stagger delays */
|
||||
.delay-1 { animation-delay: 0.1s; }
|
||||
.delay-2 { animation-delay: 0.2s; }
|
||||
.delay-3 { animation-delay: 0.3s; }
|
||||
.delay-4 { animation-delay: 0.4s; }
|
||||
.delay-5 { animation-delay: 0.5s; }
|
||||
.delay-6 { animation-delay: 0.6s; }
|
||||
|
||||
/* ─── Gradient text ─── */
|
||||
.text-gradient-primary {
|
||||
background: linear-gradient(135deg, hsl(var(--primary)), hsl(var(--primary) / 0.7));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.text-gradient-hero {
|
||||
background: linear-gradient(135deg, hsl(192 80% 38%), hsl(192 80% 42%), hsl(263 60% 52%));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.dark .text-gradient-hero {
|
||||
background: linear-gradient(135deg, hsl(192 95% 65%), hsl(192 91% 56%), hsl(263 70% 70%));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
/* ─── Card hover lift ─── */
|
||||
.card-hover {
|
||||
transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1),
|
||||
box-shadow 0.3s cubic-bezier(0.16, 1, 0.3, 1),
|
||||
border-color 0.3s ease;
|
||||
}
|
||||
|
||||
.card-hover:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 20px 40px -12px hsl(var(--primary) / 0.1),
|
||||
0 8px 16px -8px hsl(0 0% 0% / 0.2);
|
||||
border-color: hsl(var(--primary) / 0.3);
|
||||
}
|
||||
|
||||
/* ─── Scrollbar ─── */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: hsl(var(--background));
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: hsl(var(--muted-foreground) / 0.3);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: hsl(var(--muted-foreground) / 0.5);
|
||||
}
|
||||
|
||||
/* ─── Selection ─── */
|
||||
::selection {
|
||||
background: hsl(var(--primary) / 0.3);
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
/* ─── Focus ring ─── */
|
||||
.focus-ring {
|
||||
@apply focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,13 +30,13 @@ export function AuditLogPage() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-8 animate-fade-up">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold mb-2">审计日志</h1>
|
||||
<p className="text-muted-foreground">查看系统操作记录</p>
|
||||
<h1 className="text-4xl font-bold font-heading mb-2">审计日志</h1>
|
||||
<p className="text-muted-foreground text-lg">查看系统操作记录</p>
|
||||
</div>
|
||||
|
||||
<Card className="p-4">
|
||||
<Card className="p-5">
|
||||
<div className="flex gap-4">
|
||||
<Select value={actionFilter} onChange={(e) => setActionFilter(e.target.value)} className="w-[200px]">
|
||||
<option value="">全部</option>
|
||||
|
|
@ -57,7 +57,11 @@ export function AuditLogPage() {
|
|||
</Card>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8 text-muted-foreground">加载中...</div>
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="h-14 animate-shimmer rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
) : !data || data.items.length === 0 ? (
|
||||
<Card className="p-12 text-center">
|
||||
<p className="text-muted-foreground">暂无审计日志</p>
|
||||
|
|
|
|||
|
|
@ -83,13 +83,13 @@ export function AdminUsersPage() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-8 animate-fade-up">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold mb-2">用户管理</h1>
|
||||
<p className="text-muted-foreground">管理平台用户和权限</p>
|
||||
<h1 className="text-4xl font-bold font-heading mb-2">用户管理</h1>
|
||||
<p className="text-muted-foreground text-lg">管理平台用户和权限</p>
|
||||
</div>
|
||||
|
||||
<Card className="p-4">
|
||||
<Card className="p-5">
|
||||
<div className="flex gap-4">
|
||||
<Input
|
||||
placeholder="搜索用户名或邮箱..."
|
||||
|
|
@ -106,7 +106,11 @@ export function AdminUsersPage() {
|
|||
</Card>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8 text-muted-foreground">加载中...</div>
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="h-14 animate-shimmer rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
) : !data || data.items.length === 0 ? (
|
||||
<Card className="p-12 text-center">
|
||||
<p className="text-muted-foreground">暂无用户数据</p>
|
||||
|
|
@ -132,10 +136,10 @@ export function AdminUsersPage() {
|
|||
<TableCell>{user.email}</TableCell>
|
||||
<TableCell>
|
||||
<span
|
||||
className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
|
||||
className={`inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium border ${
|
||||
user.status === 'ACTIVE'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-red-100 text-red-800'
|
||||
? 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20'
|
||||
: 'bg-red-500/10 text-red-400 border-red-500/20'
|
||||
}`}
|
||||
>
|
||||
{user.status === 'ACTIVE' ? '活跃' : '已禁用'}
|
||||
|
|
|
|||
|
|
@ -6,10 +6,10 @@ export function DashboardPage() {
|
|||
const { user } = useAuth()
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-8 animate-fade-up">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Dashboard</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
<h1 className="text-4xl font-bold font-heading text-foreground">Dashboard</h1>
|
||||
<p className="text-muted-foreground mt-2 text-lg">
|
||||
管理你的账户和 API Tokens
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -19,31 +19,32 @@ export function DashboardPage() {
|
|||
<CardTitle>用户信息</CardTitle>
|
||||
<CardDescription>你的账户详情</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<CardContent className="space-y-6">
|
||||
<div className="flex items-center gap-5">
|
||||
{user?.avatarUrl && (
|
||||
<img
|
||||
src={user.avatarUrl}
|
||||
alt={user.displayName}
|
||||
className="h-16 w-16 rounded-full"
|
||||
className="h-20 w-20 rounded-2xl border-2 border-border/60 shadow-card"
|
||||
/>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
<div className="text-lg font-semibold">{user?.displayName}</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-xl font-semibold font-heading">{user?.displayName}</div>
|
||||
<div className="text-sm text-muted-foreground">{user?.email}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<div className="text-xs text-muted-foreground flex items-center gap-2">
|
||||
<span className="w-2 h-2 rounded-full bg-emerald-500" />
|
||||
通过 {user?.oauthProvider} 登录
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{user?.platformRoles && user.platformRoles.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">平台角色</div>
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm font-medium font-heading">平台角色</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{user.platformRoles.map((role) => (
|
||||
<span
|
||||
key={role}
|
||||
className="inline-flex items-center rounded-md bg-primary/10 px-2 py-1 text-xs font-medium text-primary"
|
||||
className="inline-flex items-center rounded-full bg-primary/10 px-3 py-1 text-xs font-medium text-primary border border-primary/20"
|
||||
>
|
||||
{role}
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -19,43 +19,51 @@ export function MyNamespacesPage() {
|
|||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="animate-pulse">加载中...</div>
|
||||
return (
|
||||
<div className="space-y-4 animate-fade-up">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="h-32 animate-shimmer rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-8 animate-fade-up">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold mb-2">我的命名空间</h1>
|
||||
<p className="text-muted-foreground">管理你的命名空间和团队</p>
|
||||
<h1 className="text-4xl font-bold font-heading mb-2">我的命名空间</h1>
|
||||
<p className="text-muted-foreground text-lg">管理你的命名空间和团队</p>
|
||||
</div>
|
||||
<Button disabled>创建命名空间</Button>
|
||||
</div>
|
||||
|
||||
{namespaces && namespaces.length > 0 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{namespaces.map((namespace) => (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
{namespaces.map((namespace, idx) => (
|
||||
<Card
|
||||
key={namespace.id}
|
||||
className="p-6 hover:shadow-md transition-shadow cursor-pointer"
|
||||
className={`p-6 cursor-pointer group animate-fade-up delay-${Math.min(idx + 1, 6)}`}
|
||||
onClick={() => handleNamespaceClick(namespace.slug)}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<h3 className="font-semibold text-lg">{namespace.displayName}</h3>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h3 className="font-semibold font-heading text-lg group-hover:text-primary transition-colors">
|
||||
{namespace.displayName}
|
||||
</h3>
|
||||
<NamespaceBadge
|
||||
type={namespace.type}
|
||||
name={namespace.type === 'GLOBAL' ? '全局' : '团队'}
|
||||
/>
|
||||
</div>
|
||||
{namespace.description && (
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
<p className="text-sm text-muted-foreground mb-2 leading-relaxed">
|
||||
{namespace.description}
|
||||
</p>
|
||||
)}
|
||||
<div className="text-sm text-muted-foreground">@{namespace.slug}</div>
|
||||
<div className="text-sm text-muted-foreground font-mono">@{namespace.slug}</div>
|
||||
</div>
|
||||
</div>
|
||||
{namespace.type === 'TEAM' && (
|
||||
|
|
|
|||
|
|
@ -13,41 +13,59 @@ export function MySkillsPage() {
|
|||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="animate-pulse">加载中...</div>
|
||||
return (
|
||||
<div className="space-y-4 animate-fade-up">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="h-24 animate-shimmer rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-8 animate-fade-up">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold mb-2">我的技能</h1>
|
||||
<p className="text-muted-foreground">管理你发布的技能</p>
|
||||
<h1 className="text-4xl font-bold font-heading mb-2">我的技能</h1>
|
||||
<p className="text-muted-foreground text-lg">管理你发布的技能</p>
|
||||
</div>
|
||||
<Button onClick={() => navigate({ to: '/dashboard/publish' })}>
|
||||
<Button size="lg" onClick={() => navigate({ to: '/dashboard/publish' })}>
|
||||
发布新技能
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{skills && skills.length > 0 ? (
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
{skills.map((skill) => (
|
||||
{skills.map((skill, idx) => (
|
||||
<Card
|
||||
key={skill.id}
|
||||
className="p-4 hover:shadow-md transition-shadow cursor-pointer"
|
||||
className={`p-5 cursor-pointer group animate-fade-up delay-${Math.min(idx + 1, 6)}`}
|
||||
onClick={() => handleSkillClick(skill.namespace, skill.slug)}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-lg mb-1">{skill.displayName}</h3>
|
||||
<h3 className="font-semibold font-heading text-lg mb-1 group-hover:text-primary transition-colors">
|
||||
{skill.displayName}
|
||||
</h3>
|
||||
{skill.summary && (
|
||||
<p className="text-sm text-muted-foreground mb-2">{skill.summary}</p>
|
||||
<p className="text-sm text-muted-foreground mb-3 leading-relaxed">{skill.summary}</p>
|
||||
)}
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||
<span>@{skill.namespace}</span>
|
||||
{skill.latestVersion && <span>v{skill.latestVersion}</span>}
|
||||
<span>{skill.downloadCount} 下载</span>
|
||||
<span className="px-2.5 py-0.5 rounded-full bg-secondary/60 text-xs">@{skill.namespace}</span>
|
||||
{skill.latestVersion && (
|
||||
<span className="font-mono text-xs">v{skill.latestVersion}</span>
|
||||
)}
|
||||
<span className="flex items-center gap-1">
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M9 19l3 3m0 0l3-3m-3 3V10" />
|
||||
</svg>
|
||||
{skill.downloadCount}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<svg className="w-5 h-5 text-muted-foreground group-hover:text-primary transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
|
|
@ -57,7 +75,7 @@ export function MySkillsPage() {
|
|||
title="还没有技能"
|
||||
description="开始发布你的第一个技能吧"
|
||||
action={
|
||||
<Button onClick={() => navigate({ to: '/dashboard/publish' })}>
|
||||
<Button size="lg" onClick={() => navigate({ to: '/dashboard/publish' })}>
|
||||
发布技能
|
||||
</Button>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,43 +11,56 @@ export function NamespaceMembersPage() {
|
|||
const { data: members, isLoading: isLoadingMembers } = useNamespaceMembers(slug)
|
||||
|
||||
if (isLoadingNamespace) {
|
||||
return <div className="animate-pulse">加载中...</div>
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-up">
|
||||
<div className="h-12 w-48 animate-shimmer rounded-lg" />
|
||||
<div className="h-6 w-96 animate-shimmer rounded-md" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!namespace) {
|
||||
return <div>命名空间不存在</div>
|
||||
return (
|
||||
<div className="text-center py-20 animate-fade-up">
|
||||
<h2 className="text-2xl font-bold font-heading mb-2">命名空间不存在</h2>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-8 animate-fade-up">
|
||||
<NamespaceHeader namespace={namespace} />
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold">成员管理</h2>
|
||||
<h2 className="text-2xl font-bold font-heading">成员管理</h2>
|
||||
<Button disabled>添加成员</Button>
|
||||
</div>
|
||||
|
||||
{isLoadingMembers ? (
|
||||
<div className="animate-pulse">加载中...</div>
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="h-14 animate-shimmer rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
) : members && members.length > 0 ? (
|
||||
<Card>
|
||||
<Card className="overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
<th className="text-left p-4 font-medium">用户 ID</th>
|
||||
<th className="text-left p-4 font-medium">角色</th>
|
||||
<th className="text-left p-4 font-medium">加入时间</th>
|
||||
<th className="text-right p-4 font-medium">操作</th>
|
||||
<tr className="border-b border-border/40">
|
||||
<th className="text-left p-4 font-medium font-heading text-sm text-muted-foreground">用户 ID</th>
|
||||
<th className="text-left p-4 font-medium font-heading text-sm text-muted-foreground">角色</th>
|
||||
<th className="text-left p-4 font-medium font-heading text-sm text-muted-foreground">加入时间</th>
|
||||
<th className="text-right p-4 font-medium font-heading text-sm text-muted-foreground">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{members.map((member) => (
|
||||
<tr key={member.id} className="border-b last:border-b-0">
|
||||
<td className="p-4">{member.userId}</td>
|
||||
<tr key={member.id} className="border-b border-border/40 last:border-b-0 hover:bg-secondary/30 transition-colors">
|
||||
<td className="p-4 font-medium">{member.userId}</td>
|
||||
<td className="p-4">
|
||||
<span className="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium bg-blue-100 text-blue-800">
|
||||
<span className="inline-flex items-center rounded-full px-2.5 py-1 text-xs font-medium bg-accent/10 text-accent border border-accent/20">
|
||||
{member.role}
|
||||
</span>
|
||||
</td>
|
||||
|
|
|
|||
|
|
@ -36,18 +36,18 @@ export function PublishPage() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto space-y-6">
|
||||
<div className="max-w-2xl mx-auto space-y-8 animate-fade-up">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold mb-2">发布技能</h1>
|
||||
<p className="text-muted-foreground">上传技能包到 SkillHub</p>
|
||||
<h1 className="text-4xl font-bold font-heading mb-2">发布技能</h1>
|
||||
<p className="text-muted-foreground text-lg">上传技能包到 SkillHub</p>
|
||||
</div>
|
||||
|
||||
<Card className="p-6 space-y-6">
|
||||
<Card className="p-8 space-y-8">
|
||||
{/* Namespace Selector */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="namespace">命名空间</Label>
|
||||
<div className="space-y-3">
|
||||
<Label htmlFor="namespace" className="text-sm font-semibold font-heading">命名空间</Label>
|
||||
{isLoadingNamespaces ? (
|
||||
<div className="text-sm text-muted-foreground">加载中...</div>
|
||||
<div className="h-11 animate-shimmer rounded-lg" />
|
||||
) : (
|
||||
<Select
|
||||
id="namespace"
|
||||
|
|
@ -65,8 +65,8 @@ export function PublishPage() {
|
|||
</div>
|
||||
|
||||
{/* Visibility Selector */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="visibility">可见性</Label>
|
||||
<div className="space-y-3">
|
||||
<Label htmlFor="visibility" className="text-sm font-semibold font-heading">可见性</Label>
|
||||
<Select
|
||||
id="visibility"
|
||||
value={visibility}
|
||||
|
|
@ -79,15 +79,18 @@ export function PublishPage() {
|
|||
</div>
|
||||
|
||||
{/* Upload Zone */}
|
||||
<div className="space-y-2">
|
||||
<Label>技能包文件</Label>
|
||||
<div className="space-y-3">
|
||||
<Label className="text-sm font-semibold font-heading">技能包文件</Label>
|
||||
<UploadZone
|
||||
onFileSelect={setSelectedFile}
|
||||
disabled={publishMutation.isPending}
|
||||
/>
|
||||
{selectedFile && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
已选择: {selectedFile.name} ({(selectedFile.size / 1024).toFixed(1)} KB)
|
||||
<div className="text-sm text-muted-foreground flex items-center gap-2">
|
||||
<svg className="w-4 h-4 text-emerald-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
{selectedFile.name} ({(selectedFile.size / 1024).toFixed(1)} KB)
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -95,11 +98,11 @@ export function PublishPage() {
|
|||
{/* Publish Button */}
|
||||
<Button
|
||||
className="w-full"
|
||||
size="lg"
|
||||
onClick={handlePublish}
|
||||
disabled={!selectedFile || !namespaceSlug || publishMutation.isPending}
|
||||
>
|
||||
{publishMutation.isPending ? '发布中...' : '确认发布'}
|
||||
</Button>
|
||||
{publishMutation.isPending ? '发布中...' : '确认发布'} </Button>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -53,18 +53,27 @@ export function ReviewDetailPage() {
|
|||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="text-center py-8 text-muted-foreground">加载中...</div>
|
||||
return (
|
||||
<div className="space-y-6 max-w-3xl animate-fade-up">
|
||||
<div className="h-10 w-48 animate-shimmer rounded-lg" />
|
||||
<div className="h-64 animate-shimmer rounded-xl" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!review) {
|
||||
return <div className="text-center py-8 text-muted-foreground">审核任务不存在</div>
|
||||
return (
|
||||
<div className="text-center py-20 animate-fade-up">
|
||||
<h2 className="text-2xl font-bold font-heading mb-2">审核任务不存在</h2>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-3xl">
|
||||
<div className="space-y-8 max-w-3xl animate-fade-up">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold mb-2">审核详情</h1>
|
||||
<h1 className="text-4xl font-bold font-heading mb-2">审核详情</h1>
|
||||
<p className="text-muted-foreground">审核 ID: {review.id}</p>
|
||||
</div>
|
||||
<Button variant="outline" onClick={() => navigate({ to: '/dashboard/reviews' })}>
|
||||
|
|
@ -72,46 +81,56 @@ export function ReviewDetailPage() {
|
|||
</Button>
|
||||
</div>
|
||||
|
||||
<Card className="p-6 space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label className="text-muted-foreground">技能名称</Label>
|
||||
<p className="font-medium">{review.skillName}</p>
|
||||
<Card className="p-8 space-y-6">
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground uppercase tracking-wider">技能名称</Label>
|
||||
<p className="font-semibold font-heading">{review.skillName}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-muted-foreground">命名空间/标识</Label>
|
||||
<p className="font-medium">{review.namespace}/{review.skillSlug}</p>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground uppercase tracking-wider">命名空间/标识</Label>
|
||||
<p className="font-semibold font-mono">{review.namespace}/{review.skillSlug}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-muted-foreground">版本</Label>
|
||||
<p className="font-medium">{review.version}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-muted-foreground">状态</Label>
|
||||
<p className="font-medium">
|
||||
{review.status === 'PENDING' && '待审核'}
|
||||
{review.status === 'APPROVED' && '已通过'}
|
||||
{review.status === 'REJECTED' && '已拒绝'}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground uppercase tracking-wider">版本</Label>
|
||||
<p className="font-semibold">
|
||||
<span className="px-2.5 py-0.5 rounded-full bg-primary/10 text-primary text-sm font-mono">
|
||||
{review.version}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-muted-foreground">提交者</Label>
|
||||
<p className="font-medium">{review.submittedBy}</p>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground uppercase tracking-wider">状态</Label>
|
||||
<p className="font-semibold">
|
||||
{review.status === 'PENDING' && (
|
||||
<span className="px-2.5 py-0.5 rounded-full bg-amber-500/10 text-amber-400 text-sm">待审核</span>
|
||||
)}
|
||||
{review.status === 'APPROVED' && (
|
||||
<span className="px-2.5 py-0.5 rounded-full bg-emerald-500/10 text-emerald-400 text-sm">已通过</span>
|
||||
)}
|
||||
{review.status === 'REJECTED' && (
|
||||
<span className="px-2.5 py-0.5 rounded-full bg-red-500/10 text-red-400 text-sm">已拒绝</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-muted-foreground">提交时间</Label>
|
||||
<p className="font-medium">{formatDate(review.submittedAt)}</p>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground uppercase tracking-wider">提交者</Label>
|
||||
<p className="font-semibold">{review.submittedBy}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground uppercase tracking-wider">提交时间</Label>
|
||||
<p className="font-semibold text-muted-foreground">{formatDate(review.submittedAt)}</p>
|
||||
</div>
|
||||
{review.reviewedBy && (
|
||||
<>
|
||||
<div>
|
||||
<Label className="text-muted-foreground">审核者</Label>
|
||||
<p className="font-medium">{review.reviewedBy}</p>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground uppercase tracking-wider">审核者</Label>
|
||||
<p className="font-semibold">{review.reviewedBy}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-muted-foreground">审核时间</Label>
|
||||
<p className="font-medium">
|
||||
{review.reviewedAt ? formatDate(review.reviewedAt) : '-'}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground uppercase tracking-wider">审核时间</Label>
|
||||
<p className="font-semibold text-muted-foreground">
|
||||
{review.reviewedAt ? formatDate(review.reviewedAt) : '—'}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
|
|
@ -119,19 +138,19 @@ export function ReviewDetailPage() {
|
|||
</div>
|
||||
|
||||
{review.comment && (
|
||||
<div>
|
||||
<Label className="text-muted-foreground">审核意见</Label>
|
||||
<p className="mt-2 p-3 bg-muted rounded-md">{review.comment}</p>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-muted-foreground uppercase tracking-wider">审核意见</Label>
|
||||
<p className="p-4 bg-secondary/50 rounded-xl text-sm leading-relaxed">{review.comment}</p>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{review.status === 'PENDING' && (
|
||||
<Card className="p-6 space-y-4">
|
||||
<h2 className="text-xl font-semibold">审核操作</h2>
|
||||
<Card className="p-8 space-y-6">
|
||||
<h2 className="text-xl font-bold font-heading">审核操作</h2>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="comment">审核意见(可选)</Label>
|
||||
<div className="space-y-3">
|
||||
<Label htmlFor="comment" className="text-sm font-semibold font-heading">审核意见(可选)</Label>
|
||||
<Textarea
|
||||
id="comment"
|
||||
placeholder="填写审核意见..."
|
||||
|
|
|
|||
|
|
@ -27,7 +27,13 @@ export function ReviewsPage() {
|
|||
|
||||
const renderReviewTable = (reviews: typeof pendingReviews, isLoading: boolean, status: string) => {
|
||||
if (isLoading) {
|
||||
return <div className="text-center py-8 text-muted-foreground">加载中...</div>
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="h-14 animate-shimmer rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!reviews || reviews.length === 0) {
|
||||
|
|
@ -39,7 +45,7 @@ export function ReviewsPage() {
|
|||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Card className="overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
|
|
@ -55,21 +61,25 @@ export function ReviewsPage() {
|
|||
{reviews.map((review) => (
|
||||
<TableRow
|
||||
key={review.id}
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
className="cursor-pointer hover:bg-secondary/50 transition-colors"
|
||||
onClick={() => handleRowClick(review.id)}
|
||||
>
|
||||
<TableCell className="font-medium">
|
||||
<TableCell className="font-medium font-heading">
|
||||
{review.namespace}/{review.skillSlug}
|
||||
</TableCell>
|
||||
<TableCell>{review.version}</TableCell>
|
||||
<TableCell>
|
||||
<span className="font-mono text-xs px-2 py-0.5 rounded-full bg-secondary/60">
|
||||
{review.version}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>{review.submittedBy}</TableCell>
|
||||
<TableCell>{formatDate(review.submittedAt)}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{formatDate(review.submittedAt)}</TableCell>
|
||||
{status !== 'PENDING' && (
|
||||
<TableCell>{review.reviewedBy || '-'}</TableCell>
|
||||
<TableCell>{review.reviewedBy || '—'}</TableCell>
|
||||
)}
|
||||
{status !== 'PENDING' && (
|
||||
<TableCell>
|
||||
{review.reviewedAt ? formatDate(review.reviewedAt) : '-'}
|
||||
<TableCell className="text-muted-foreground">
|
||||
{review.reviewedAt ? formatDate(review.reviewedAt) : '—'}
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
|
|
@ -81,10 +91,10 @@ export function ReviewsPage() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-8 animate-fade-up">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold mb-2">审核中心</h1>
|
||||
<p className="text-muted-foreground">管理技能版本审核</p>
|
||||
<h1 className="text-4xl font-bold font-heading mb-2">审核中心</h1>
|
||||
<p className="text-muted-foreground text-lg">管理技能版本审核</p>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="PENDING">
|
||||
|
|
@ -94,15 +104,15 @@ export function ReviewsPage() {
|
|||
<TabsTrigger value="REJECTED">已拒绝</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="PENDING" className="mt-4">
|
||||
<TabsContent value="PENDING" className="mt-6">
|
||||
{renderReviewTable(pendingReviews, isPendingLoading, 'PENDING')}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="APPROVED" className="mt-4">
|
||||
<TabsContent value="APPROVED" className="mt-6">
|
||||
{renderReviewTable(approvedReviews, isApprovedLoading, 'APPROVED')}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="REJECTED" className="mt-4">
|
||||
<TabsContent value="REJECTED" className="mt-6">
|
||||
{renderReviewTable(rejectedReviews, isRejectedLoading, 'REJECTED')}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
|
|
|||
|
|
@ -86,10 +86,15 @@ export function DeviceAuthPage() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4">
|
||||
<Card className="w-full max-w-md p-8 space-y-6">
|
||||
<div className="text-center space-y-2">
|
||||
<h1 className="text-3xl font-bold">设备授权</h1>
|
||||
<div className="min-h-[70vh] flex items-center justify-center p-4 animate-fade-up">
|
||||
<Card className="w-full max-w-md p-8 space-y-8">
|
||||
<div className="text-center space-y-3">
|
||||
<div className="inline-flex w-16 h-16 rounded-2xl bg-gradient-to-br from-primary to-accent items-center justify-center shadow-glow mb-2 mx-auto">
|
||||
<svg className="w-8 h-8 text-primary-foreground" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold font-heading">设备授权</h1>
|
||||
<p className="text-muted-foreground">
|
||||
请输入设备上显示的 8 位用户码
|
||||
</p>
|
||||
|
|
@ -128,10 +133,10 @@ export function DeviceAuthPage() {
|
|||
|
||||
{message && (
|
||||
<div
|
||||
className={`p-4 rounded-md ${
|
||||
className={`p-4 rounded-xl text-sm ${
|
||||
message.type === 'success'
|
||||
? 'bg-green-50 text-green-800 border border-green-200'
|
||||
: 'bg-red-50 text-red-800 border border-red-200'
|
||||
? 'bg-emerald-500/10 text-emerald-400 border border-emerald-500/20'
|
||||
: 'bg-red-500/10 text-red-400 border border-red-500/20'
|
||||
}`}
|
||||
>
|
||||
{message.text}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { SearchBar } from '@/features/search/search-bar'
|
|||
import { SkillCard } from '@/features/skill/skill-card'
|
||||
import { SkeletonList } from '@/shared/components/skeleton-loader'
|
||||
import { useSearchSkills } from '@/shared/hooks/use-skill-queries'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
|
||||
export function HomePage() {
|
||||
const navigate = useNavigate()
|
||||
|
|
@ -26,49 +27,90 @@ export function HomePage() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-12">
|
||||
<div className="space-y-20">
|
||||
{/* Hero Section */}
|
||||
<div className="text-center space-y-6 py-12">
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-5xl font-bold">SkillHub</h1>
|
||||
<p className="text-xl text-muted-foreground">技能注册中心</p>
|
||||
<div className="text-center space-y-8 py-16 animate-fade-up">
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-6xl md:text-7xl lg:text-8xl font-bold font-display text-gradient-hero leading-tight">
|
||||
SkillHub
|
||||
</h1>
|
||||
<p className="text-xl md:text-2xl text-muted-foreground font-light max-w-2xl mx-auto">
|
||||
现代化的技能注册中心
|
||||
</p>
|
||||
<p className="text-base text-muted-foreground/80 max-w-xl mx-auto">
|
||||
为开发者提供高效的技能管理、分发和协作平台
|
||||
</p>
|
||||
</div>
|
||||
<div className="max-w-2xl mx-auto">
|
||||
|
||||
<div className="max-w-2xl mx-auto animate-fade-up delay-1">
|
||||
<SearchBar onSearch={handleSearch} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center gap-4 animate-fade-up delay-2">
|
||||
<Button size="lg" onClick={() => navigate({ to: '/search', search: { q: '', sort: 'relevance', page: 1 } })}>
|
||||
浏览技能
|
||||
</Button>
|
||||
<Button size="lg" variant="outline" onClick={() => navigate({ to: '/dashboard/publish' })}>
|
||||
发布技能
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Popular Downloads Section */}
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-2xl font-semibold">热门下载</h2>
|
||||
<section className="space-y-6 animate-fade-up delay-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold font-heading text-foreground mb-2">热门下载</h2>
|
||||
<p className="text-muted-foreground">社区最受欢迎的技能</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => navigate({ to: '/search', search: { q: '', sort: 'downloads', page: 1 } })}
|
||||
>
|
||||
查看全部 →
|
||||
</Button>
|
||||
</div>
|
||||
{isLoadingPopular ? (
|
||||
<SkeletonList count={6} />
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{popularSkills?.items.map((skill) => (
|
||||
<SkillCard
|
||||
key={skill.id}
|
||||
skill={skill}
|
||||
onClick={() => handleSkillClick(skill.namespace, skill.slug)}
|
||||
/>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||
{popularSkills?.items.map((skill, idx) => (
|
||||
<div key={skill.id} className={`animate-fade-up delay-${Math.min(idx + 1, 6)}`}>
|
||||
<SkillCard
|
||||
skill={skill}
|
||||
onClick={() => handleSkillClick(skill.namespace, skill.slug)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Latest Releases Section */}
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-2xl font-semibold">最新发布</h2>
|
||||
<section className="space-y-6 animate-fade-up delay-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold font-heading text-foreground mb-2">最新发布</h2>
|
||||
<p className="text-muted-foreground">刚刚发布的新技能</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => navigate({ to: '/search', search: { q: '', sort: 'newest', page: 1 } })}
|
||||
>
|
||||
查看全部 →
|
||||
</Button>
|
||||
</div>
|
||||
{isLoadingLatest ? (
|
||||
<SkeletonList count={6} />
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{latestSkills?.items.map((skill) => (
|
||||
<SkillCard
|
||||
key={skill.id}
|
||||
skill={skill}
|
||||
onClick={() => handleSkillClick(skill.namespace, skill.slug)}
|
||||
/>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||
{latestSkills?.items.map((skill, idx) => (
|
||||
<div key={skill.id} className={`animate-fade-up delay-${Math.min(idx + 1, 6)}`}>
|
||||
<SkillCard
|
||||
skill={skill}
|
||||
onClick={() => handleSkillClick(skill.namespace, skill.slug)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -2,15 +2,28 @@ import { LoginButton } from '@/features/auth/login-button'
|
|||
|
||||
export function LoginPage() {
|
||||
return (
|
||||
<div className="flex min-h-[60vh] items-center justify-center">
|
||||
<div className="w-full max-w-sm space-y-6">
|
||||
<div className="space-y-2 text-center">
|
||||
<h1 className="text-3xl font-bold">登录 SkillHub</h1>
|
||||
<p className="text-muted-foreground">
|
||||
<div className="flex min-h-[70vh] items-center justify-center">
|
||||
<div className="w-full max-w-md space-y-8 animate-fade-up">
|
||||
<div className="text-center space-y-3">
|
||||
<div className="inline-flex w-16 h-16 rounded-2xl bg-gradient-to-br from-primary to-primary/70 items-center justify-center shadow-glow mb-4">
|
||||
<span className="text-primary-foreground font-bold text-2xl">S</span>
|
||||
</div>
|
||||
<h1 className="text-4xl font-bold font-heading text-foreground">登录 SkillHub</h1>
|
||||
<p className="text-muted-foreground text-lg">
|
||||
选择一个方式登录以继续
|
||||
</p>
|
||||
</div>
|
||||
<LoginButton />
|
||||
|
||||
<div className="glass-strong p-8 rounded-2xl">
|
||||
<LoginButton />
|
||||
</div>
|
||||
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
登录即表示你同意我们的
|
||||
<a href="#" className="text-primary hover:underline ml-1">服务条款</a>
|
||||
和
|
||||
<a href="#" className="text-primary hover:underline ml-1">隐私政策</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -20,7 +20,12 @@ export function NamespacePage() {
|
|||
}
|
||||
|
||||
if (isLoadingNamespace) {
|
||||
return <div className="animate-pulse">加载中...</div>
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-up">
|
||||
<div className="h-12 w-48 animate-shimmer rounded-lg" />
|
||||
<div className="h-6 w-96 animate-shimmer rounded-md" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!namespaceData) {
|
||||
|
|
@ -28,21 +33,22 @@ export function NamespacePage() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-8 animate-fade-up">
|
||||
<NamespaceHeader namespace={namespaceData} />
|
||||
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-xl font-semibold">技能列表</h2>
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-2xl font-bold font-heading">技能列表</h2>
|
||||
{isLoadingSkills ? (
|
||||
<SkeletonList count={6} />
|
||||
) : skillsData && skillsData.items.length > 0 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{skillsData.items.map((skill) => (
|
||||
<SkillCard
|
||||
key={skill.id}
|
||||
skill={skill}
|
||||
onClick={() => handleSkillClick(skill.slug)}
|
||||
/>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||
{skillsData.items.map((skill, idx) => (
|
||||
<div key={skill.id} className={`animate-fade-up delay-${Math.min(idx + 1, 6)}`}>
|
||||
<SkillCard
|
||||
skill={skill}
|
||||
onClick={() => handleSkillClick(skill.slug)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -41,38 +41,46 @@ export function SearchPage() {
|
|||
const totalPages = data ? Math.ceil(data.total / data.size) : 0
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-8 animate-fade-up">
|
||||
{/* Search Bar */}
|
||||
<div className="max-w-2xl">
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<SearchBar defaultValue={q} onSearch={handleSearch} />
|
||||
</div>
|
||||
|
||||
{/* Sort Selector */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">排序:</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant={sort === 'relevance' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => handleSortChange('relevance')}
|
||||
>
|
||||
相关性
|
||||
</Button>
|
||||
<Button
|
||||
variant={sort === 'downloads' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => handleSortChange('downloads')}
|
||||
>
|
||||
下载量
|
||||
</Button>
|
||||
<Button
|
||||
variant={sort === 'newest' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => handleSortChange('newest')}
|
||||
>
|
||||
最新
|
||||
</Button>
|
||||
<div className="flex items-center justify-between flex-wrap gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm font-medium text-muted-foreground">排序:</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant={sort === 'relevance' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => handleSortChange('relevance')}
|
||||
>
|
||||
相关性
|
||||
</Button>
|
||||
<Button
|
||||
variant={sort === 'downloads' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => handleSortChange('downloads')}
|
||||
>
|
||||
下载量
|
||||
</Button>
|
||||
<Button
|
||||
variant={sort === 'newest' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => handleSortChange('newest')}
|
||||
>
|
||||
最新
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{data && data.total > 0 && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
找到 <span className="text-primary font-semibold">{data.total}</span> 个结果
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
|
|
@ -80,16 +88,14 @@ export function SearchPage() {
|
|||
<SkeletonList count={12} />
|
||||
) : data && data.items.length > 0 ? (
|
||||
<>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
找到 {data.total} 个结果
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{data.items.map((skill) => (
|
||||
<SkillCard
|
||||
key={skill.id}
|
||||
skill={skill}
|
||||
onClick={() => handleSkillClick(skill.namespace, skill.slug)}
|
||||
/>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||
{data.items.map((skill, idx) => (
|
||||
<div key={skill.id} className={`animate-fade-up delay-${Math.min(idx % 6 + 1, 6)}`}>
|
||||
<SkillCard
|
||||
skill={skill}
|
||||
onClick={() => handleSkillClick(skill.namespace, skill.slug)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{totalPages > 1 && (
|
||||
|
|
|
|||
|
|
@ -23,21 +23,35 @@ export function SkillDetailPage() {
|
|||
const { data: readme } = useSkillReadme(namespace, slug, latestVersion?.version)
|
||||
|
||||
if (isLoadingSkill) {
|
||||
return <div className="animate-pulse">加载中...</div>
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-up">
|
||||
<div className="h-10 w-64 animate-shimmer rounded-lg" />
|
||||
<div className="h-5 w-96 animate-shimmer rounded-md" />
|
||||
<div className="h-64 animate-shimmer rounded-xl" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!skill) {
|
||||
return <div>技能不存在</div>
|
||||
return (
|
||||
<div className="text-center py-20 animate-fade-up">
|
||||
<h2 className="text-2xl font-bold font-heading mb-2">技能不存在</h2>
|
||||
<p className="text-muted-foreground">该技能可能已被删除或从未存在</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8 animate-fade-up">
|
||||
{/* Main Content */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold mb-2">{skill.displayName}</h1>
|
||||
<div className="lg:col-span-2 space-y-8">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<NamespaceBadge type="GLOBAL" name={`@${namespace}`} />
|
||||
</div>
|
||||
<h1 className="text-4xl font-bold font-heading text-foreground">{skill.displayName}</h1>
|
||||
{skill.summary && (
|
||||
<p className="text-muted-foreground">{skill.summary}</p>
|
||||
<p className="text-lg text-muted-foreground leading-relaxed">{skill.summary}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
|
@ -48,51 +62,57 @@ export function SkillDetailPage() {
|
|||
<TabsTrigger value="versions">版本</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="readme" className="mt-4">
|
||||
<TabsContent value="readme" className="mt-6">
|
||||
{readme ? (
|
||||
<Card className="p-6">
|
||||
<Card className="p-8">
|
||||
<MarkdownRenderer content={readme} />
|
||||
</Card>
|
||||
) : (
|
||||
<Card className="p-6 text-muted-foreground">
|
||||
<Card className="p-8 text-muted-foreground text-center">
|
||||
暂无 README
|
||||
</Card>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="files" className="mt-4">
|
||||
<TabsContent value="files" className="mt-6">
|
||||
{files && files.length > 0 ? (
|
||||
<FileTree files={files} />
|
||||
) : (
|
||||
<Card className="p-6 text-muted-foreground">
|
||||
<Card className="p-8 text-muted-foreground text-center">
|
||||
暂无文件
|
||||
</Card>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="versions" className="mt-4">
|
||||
<TabsContent value="versions" className="mt-6">
|
||||
<Card className="p-6">
|
||||
{versions && versions.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-0 divide-y divide-border/40">
|
||||
{versions.map((version) => (
|
||||
<div key={version.id} className="border-b pb-4 last:border-b-0">
|
||||
<div key={version.id} className="py-5 first:pt-0 last:pb-0">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="font-semibold">v{version.version}</span>
|
||||
<span className="font-semibold font-heading text-foreground flex items-center gap-2">
|
||||
<span className="px-2.5 py-0.5 rounded-full bg-primary/10 text-primary text-sm font-mono">
|
||||
v{version.version}
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{new Date(version.publishedAt).toLocaleDateString('zh-CN')}
|
||||
</span>
|
||||
</div>
|
||||
{version.changelog && (
|
||||
<p className="text-sm text-muted-foreground">{version.changelog}</p>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">{version.changelog}</p>
|
||||
)}
|
||||
<div className="text-xs text-muted-foreground mt-2">
|
||||
{version.fileCount} 个文件 · {(version.totalSize / 1024).toFixed(1)} KB
|
||||
<div className="text-xs text-muted-foreground mt-2 flex items-center gap-3">
|
||||
<span>{version.fileCount} 个文件</span>
|
||||
<span className="w-1 h-1 rounded-full bg-muted-foreground/40" />
|
||||
<span>{(version.totalSize / 1024).toFixed(1)} KB</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-muted-foreground">暂无版本</div>
|
||||
<div className="text-muted-foreground text-center py-8">暂无版本</div>
|
||||
)}
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
|
@ -100,29 +120,33 @@ export function SkillDetailPage() {
|
|||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="space-y-4">
|
||||
<Card className="p-4 space-y-4">
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground mb-1">版本</div>
|
||||
<div className="font-semibold">
|
||||
{skill.latestVersion ? `v${skill.latestVersion}` : '暂无版本'}
|
||||
<div className="space-y-5">
|
||||
<Card className="p-5 space-y-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm text-muted-foreground">版本</div>
|
||||
<div className="font-semibold font-mono text-foreground">
|
||||
{skill.latestVersion ? `v${skill.latestVersion}` : '—'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground mb-1">下载量</div>
|
||||
<div className="font-semibold">{skill.downloadCount}</div>
|
||||
<div className="h-px bg-border/40" />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm text-muted-foreground">下载量</div>
|
||||
<div className="font-semibold text-foreground">{skill.downloadCount.toLocaleString()}</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground mb-1">命名空间</div>
|
||||
<NamespaceBadge type="GLOBAL" name={namespace} />
|
||||
<div className="h-px bg-border/40" />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm text-muted-foreground">命名空间</div>
|
||||
<NamespaceBadge type="GLOBAL" name={`@${namespace}`} />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{skill.latestVersion && (
|
||||
<Card className="p-4 space-y-4">
|
||||
<div className="text-sm font-medium">安装</div>
|
||||
<Card className="p-5 space-y-4">
|
||||
<div className="text-sm font-semibold font-heading text-foreground">安装</div>
|
||||
<InstallCommand
|
||||
namespace={namespace}
|
||||
slug={slug}
|
||||
|
|
@ -131,7 +155,10 @@ export function SkillDetailPage() {
|
|||
</Card>
|
||||
)}
|
||||
|
||||
<Button className="w-full" variant="outline">
|
||||
<Button className="w-full" variant="outline" size="lg">
|
||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M9 19l3 3m0 0l3-3m-3 3V10" />
|
||||
</svg>
|
||||
下载
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -8,12 +8,27 @@ interface EmptyStateProps {
|
|||
|
||||
export function EmptyState({ title, description, action }: EmptyStateProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<h3 className="text-lg font-semibold text-foreground">{title}</h3>
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center animate-fade-up">
|
||||
<div className="w-16 h-16 rounded-2xl bg-secondary/60 flex items-center justify-center mb-5">
|
||||
<svg
|
||||
className="w-8 h-8 text-muted-foreground"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold font-heading text-foreground mb-2">{title}</h3>
|
||||
{description && (
|
||||
<p className="mt-2 text-sm text-muted-foreground">{description}</p>
|
||||
<p className="text-sm text-muted-foreground max-w-md mb-6">{description}</p>
|
||||
)}
|
||||
{action && <div className="mt-4">{action}</div>}
|
||||
{action && <div>{action}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,10 +10,10 @@ export function NamespaceBadge({ type, name, className }: NamespaceBadgeProps) {
|
|||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium',
|
||||
'inline-flex items-center rounded-full px-3 py-1 text-xs font-medium border transition-colors',
|
||||
type === 'GLOBAL'
|
||||
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
|
||||
: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200',
|
||||
? 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20 hover:bg-emerald-500/15'
|
||||
: 'bg-accent/10 text-accent border-accent/20 hover:bg-accent/15',
|
||||
className
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -8,23 +8,29 @@ interface PaginationProps {
|
|||
|
||||
export function Pagination({ page, totalPages, onPageChange }: PaginationProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<div className="flex items-center justify-center gap-3 py-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onPageChange(page - 1)}
|
||||
disabled={page <= 1}
|
||||
className="min-w-[90px]"
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
第 {page} / {totalPages} 页
|
||||
</span>
|
||||
<div className="flex items-center gap-2 px-4 py-1.5 rounded-lg bg-secondary/40 text-sm font-medium text-foreground">
|
||||
<span className="text-muted-foreground">第</span>
|
||||
<span className="text-primary">{page}</span>
|
||||
<span className="text-muted-foreground">/</span>
|
||||
<span>{totalPages}</span>
|
||||
<span className="text-muted-foreground">页</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onPageChange(page + 1)}
|
||||
disabled={page >= totalPages}
|
||||
className="min-w-[90px]"
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
export function SkeletonCard() {
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-4 animate-pulse">
|
||||
<div className="h-4 bg-muted rounded w-3/4 mb-3"></div>
|
||||
<div className="h-3 bg-muted rounded w-full mb-2"></div>
|
||||
<div className="h-3 bg-muted rounded w-5/6"></div>
|
||||
<div className="flex gap-2 mt-4">
|
||||
<div className="h-3 bg-muted rounded w-16"></div>
|
||||
<div className="h-3 bg-muted rounded w-16"></div>
|
||||
<div className="h-3 bg-muted rounded w-16"></div>
|
||||
<div className="rounded-xl border border-border/40 bg-card p-5">
|
||||
<div className="h-5 animate-shimmer rounded-lg w-3/4 mb-4"></div>
|
||||
<div className="h-3 animate-shimmer rounded-md w-full mb-2.5"></div>
|
||||
<div className="h-3 animate-shimmer rounded-md w-5/6 mb-5"></div>
|
||||
<div className="flex gap-3 mt-4">
|
||||
<div className="h-6 animate-shimmer rounded-full w-16"></div>
|
||||
<div className="h-6 animate-shimmer rounded-full w-20"></div>
|
||||
<div className="h-6 animate-shimmer rounded-full w-14"></div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
@ -19,7 +19,7 @@ interface SkeletonListProps {
|
|||
|
||||
export function SkeletonList({ count = 6 }: SkeletonListProps) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<SkeletonCard key={i} />
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -3,21 +3,27 @@ import { cva, type VariantProps } from 'class-variance-authority'
|
|||
import { cn } from '@/shared/lib/utils'
|
||||
|
||||
const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
|
||||
'inline-flex items-center justify-center whitespace-nowrap rounded-lg text-sm font-medium transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
||||
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
|
||||
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
default:
|
||||
'bg-primary text-primary-foreground shadow-glow hover:brightness-110 hover:shadow-glow-lg active:scale-[0.98]',
|
||||
destructive:
|
||||
'bg-destructive text-destructive-foreground hover:bg-destructive/90 active:scale-[0.98]',
|
||||
outline:
|
||||
'border border-border bg-transparent hover:bg-secondary hover:border-primary/30 hover:text-foreground active:scale-[0.98]',
|
||||
secondary:
|
||||
'bg-secondary text-secondary-foreground hover:bg-secondary/80 active:scale-[0.98]',
|
||||
ghost:
|
||||
'hover:bg-secondary hover:text-foreground',
|
||||
link:
|
||||
'text-primary underline-offset-4 hover:underline',
|
||||
},
|
||||
size: {
|
||||
default: 'h-10 px-4 py-2',
|
||||
sm: 'h-9 rounded-md px-3',
|
||||
lg: 'h-11 rounded-md px-8',
|
||||
default: 'h-10 px-5 py-2',
|
||||
sm: 'h-8 rounded-md px-3 text-xs',
|
||||
lg: 'h-12 rounded-xl px-8 text-base',
|
||||
icon: 'h-10 w-10',
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -5,7 +5,10 @@ const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElemen
|
|||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('rounded-lg border bg-card text-card-foreground shadow-sm', className)}
|
||||
className={cn(
|
||||
'rounded-xl border border-border/60 bg-card text-card-foreground shadow-card card-hover',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
|
@ -21,7 +24,7 @@ CardHeader.displayName = 'CardHeader'
|
|||
|
||||
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<h3 ref={ref} className={cn('text-2xl font-semibold leading-none tracking-tight', className)} {...props} />
|
||||
<h3 ref={ref} className={cn('text-xl font-semibold font-heading leading-none tracking-tight', className)} {...props} />
|
||||
)
|
||||
)
|
||||
CardTitle.displayName = 'CardTitle'
|
||||
|
|
|
|||
|
|
@ -75,7 +75,7 @@ const DialogOverlay = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTML
|
|||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
'fixed inset-0 z-50 bg-black/60 backdrop-blur-sm',
|
||||
className
|
||||
)}
|
||||
onClick={() => onOpenChange(false)}
|
||||
|
|
@ -96,7 +96,7 @@ const DialogContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTML
|
|||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative z-50 grid w-full max-w-lg gap-4 border bg-background p-6 shadow-lg duration-200 sm:rounded-lg',
|
||||
'relative z-50 grid w-full max-w-lg gap-4 border border-border/60 bg-card p-8 shadow-card rounded-2xl animate-fade-up',
|
||||
className
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
|
|
@ -144,7 +144,7 @@ DialogFooter.displayName = 'DialogFooter'
|
|||
|
||||
const DialogTitle = React.forwardRef<HTMLHeadingElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<h2 ref={ref} className={cn('text-lg font-semibold leading-none tracking-tight', className)} {...props} />
|
||||
<h2 ref={ref} className={cn('text-xl font-bold font-heading leading-none tracking-tight', className)} {...props} />
|
||||
)
|
||||
)
|
||||
DialogTitle.displayName = 'DialogTitle'
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
|||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'flex h-11 w-full rounded-lg border border-border/60 bg-secondary/50 px-4 py-2 text-sm text-foreground ring-offset-background transition-all duration-200 file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 focus-visible:border-primary/50 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
|
|||
return (
|
||||
<select
|
||||
className={cn(
|
||||
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'flex h-11 w-full rounded-lg border border-border/60 bg-secondary/50 px-4 py-2 text-sm text-foreground ring-offset-background transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 focus-visible:border-primary/50 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableE
|
|||
Table.displayName = 'Table'
|
||||
|
||||
const TableHeader = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
|
||||
({ className, ...props }, ref) => <thead ref={ref} className={cn('[&_tr]:border-b', className)} {...props} />
|
||||
({ className, ...props }, ref) => <thead ref={ref} className={cn('[&_tr]:border-b [&_tr]:border-border/40', className)} {...props} />
|
||||
)
|
||||
TableHeader.displayName = 'TableHeader'
|
||||
|
||||
|
|
@ -26,7 +26,7 @@ const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTML
|
|||
({ className, ...props }, ref) => (
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn('border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted', className)}
|
||||
className={cn('border-b border-border/40 transition-colors hover:bg-secondary/30 data-[state=selected]:bg-secondary', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
|
@ -38,7 +38,7 @@ const TableHead = React.forwardRef<HTMLTableCellElement, React.ThHTMLAttributes<
|
|||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0',
|
||||
'h-12 px-4 text-left align-middle font-medium font-heading text-sm text-muted-foreground [&:has([role=checkbox])]:pr-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ export function TabsList({ children, className }: TabsListProps) {
|
|||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground',
|
||||
'inline-flex h-11 items-center gap-1 rounded-xl bg-secondary/60 p-1 text-muted-foreground',
|
||||
className
|
||||
)}
|
||||
>
|
||||
|
|
@ -59,10 +59,10 @@ export function TabsTrigger({ value, children, className }: TabsTriggerProps) {
|
|||
type="button"
|
||||
onClick={() => context.setValue(value)}
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
|
||||
'inline-flex items-center justify-center whitespace-nowrap rounded-lg px-4 py-1.5 text-sm font-medium transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50',
|
||||
isActive
|
||||
? 'bg-background text-foreground shadow-sm'
|
||||
: 'hover:bg-background/50',
|
||||
? 'bg-card text-foreground shadow-sm border border-border/40'
|
||||
: 'hover:text-foreground/80 hover:bg-secondary',
|
||||
className
|
||||
)}
|
||||
>
|
||||
|
|
@ -83,5 +83,5 @@ export function TabsContent({ value, children, className }: TabsContentProps) {
|
|||
|
||||
if (context.value !== value) return null
|
||||
|
||||
return <div className={className}>{children}</div>
|
||||
return <div className={cn('animate-fade-in', className)}>{children}</div>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
|||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
'flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'flex min-h-[100px] w-full rounded-lg border border-border/60 bg-secondary/50 px-4 py-3 text-sm text-foreground ring-offset-background transition-all duration-200 placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 focus-visible:border-primary/50 disabled:cursor-not-allowed disabled:opacity-50 resize-y',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
|
|
|
|||
|
|
@ -5,10 +5,18 @@ const config: Config = {
|
|||
content: ['./index.html', './src/**/*.{ts,tsx}'],
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
display: ['Playfair Display', 'Georgia', 'serif'],
|
||||
heading: ['Outfit', 'DM Sans', 'system-ui', 'sans-serif'],
|
||||
sans: ['DM Sans', 'system-ui', 'sans-serif'],
|
||||
mono: ['JetBrains Mono', 'ui-monospace', 'monospace'],
|
||||
},
|
||||
borderRadius: {
|
||||
lg: 'var(--radius)',
|
||||
md: 'calc(var(--radius) - 2px)',
|
||||
sm: 'calc(var(--radius) - 4px)',
|
||||
xl: 'calc(var(--radius) + 4px)',
|
||||
'2xl': 'calc(var(--radius) + 8px)',
|
||||
},
|
||||
colors: {
|
||||
background: 'hsl(var(--background))',
|
||||
|
|
@ -45,6 +53,41 @@ const config: Config = {
|
|||
input: 'hsl(var(--input))',
|
||||
ring: 'hsl(var(--ring))',
|
||||
},
|
||||
boxShadow: {
|
||||
glow: '0 0 20px -5px hsl(var(--primary) / 0.3)',
|
||||
'glow-lg': '0 0 40px -10px hsl(var(--primary) / 0.25)',
|
||||
'card': '0 4px 24px -4px hsl(0 0% 0% / 0.15)',
|
||||
'card-hover': '0 20px 40px -12px hsl(var(--primary) / 0.1), 0 8px 16px -8px hsl(0 0% 0% / 0.2)',
|
||||
},
|
||||
animation: {
|
||||
'fade-up': 'fade-up 0.6s cubic-bezier(0.16, 1, 0.3, 1) both',
|
||||
'fade-in': 'fade-in 0.5s ease both',
|
||||
'slide-in-right': 'slide-in-right 0.5s cubic-bezier(0.16, 1, 0.3, 1) both',
|
||||
'shimmer': 'shimmer 1.8s ease-in-out infinite',
|
||||
'float': 'float 6s ease-in-out infinite',
|
||||
},
|
||||
keyframes: {
|
||||
'fade-up': {
|
||||
from: { opacity: '0', transform: 'translateY(20px)' },
|
||||
to: { opacity: '1', transform: 'translateY(0)' },
|
||||
},
|
||||
'fade-in': {
|
||||
from: { opacity: '0' },
|
||||
to: { opacity: '1' },
|
||||
},
|
||||
'slide-in-right': {
|
||||
from: { opacity: '0', transform: 'translateX(16px)' },
|
||||
to: { opacity: '1', transform: 'translateX(0)' },
|
||||
},
|
||||
shimmer: {
|
||||
'0%': { backgroundPosition: '-200% 0' },
|
||||
'100%': { backgroundPosition: '200% 0' },
|
||||
},
|
||||
float: {
|
||||
'0%, 100%': { transform: 'translateY(0)' },
|
||||
'50%': { transform: 'translateY(-8px)' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue