From 47c9a1d1db8323dac706e4a585be69c0489f4536 Mon Sep 17 00:00:00 2001 From: vsxd Date: Thu, 12 Mar 2026 20:53:24 +0800 Subject: [PATCH 01/38] feat(ops): publish runtime images via github actions --- .env.release.example | 16 + .github/workflows/publish-images.yml | 73 +++++ README.md | 33 +- compose.release.yml | 72 +++++ docker-compose.prod.yml | 86 ------ docs/01-system-architecture.md | 21 +- docs/09-deployment.md | 432 ++++++++------------------- 7 files changed, 339 insertions(+), 394 deletions(-) create mode 100644 .env.release.example create mode 100644 .github/workflows/publish-images.yml create mode 100644 compose.release.yml delete mode 100644 docker-compose.prod.yml diff --git a/.env.release.example b/.env.release.example new file mode 100644 index 00000000..4ee48cf6 --- /dev/null +++ b/.env.release.example @@ -0,0 +1,16 @@ +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 diff --git a/.github/workflows/publish-images.yml b/.github/workflows/publish-images.yml new file mode 100644 index 00000000..42baa28c --- /dev/null +++ b/.github/workflows/publish-images.yml @@ -0,0 +1,73 @@ +name: Publish Images + +on: + push: + branches: + - main + tags: + - "v*.*.*" + workflow_dispatch: + +concurrency: + group: publish-images-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + packages: write + +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: 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 }} + 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 }} diff --git a/README.md b/README.md index c0695001..22cc3012 100644 --- a/README.md +++ b/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,30 @@ 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. +To start a single-node local stack from published images: + +```bash +cp .env.release.example .env.release +docker compose --env-file .env.release -f compose.release.yml up -d +``` + +Then open: + +- Web UI: `http://localhost` +- Backend API: `http://localhost:8080` + +This runtime uses the existing `local,docker` profile combination so it +is immediately usable with the same mock-auth flow as local development: + +- `local-user` +- `local-admin` + +Pass `X-Mock-User-Id` to the backend when you need an authenticated +session without configuring GitHub OAuth. + ## Architecture ``` @@ -82,7 +113,7 @@ Run `make help` to see all available commands. ┌────────────┼────────────┐ │ │ │ ┌──────▼───┐ ┌─────▼────┐ ┌───▼────┐ - │PostgreSQL│ │ Redis │ │ MinIO │ + │PostgreSQL│ │ Redis │ │ Storage │ └──────────┘ └──────────┘ └────────┘ ``` diff --git a/compose.release.yml b/compose.release.yml new file mode 100644 index 00000000..0b3f5823 --- /dev/null +++ b/compose.release.yml @@ -0,0 +1,72 @@ +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 + DB_HOST: postgres + DB_PORT: 5432 + DB_NAME: ${POSTGRES_DB:-skillhub} + DB_USER: ${POSTGRES_USER:-skillhub} + DB_PASS: ${POSTGRES_PASSWORD:-skillhub_demo} + REDIS_HOST: redis + 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: 30s + + 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 + +volumes: + postgres_data: + skillhub_storage: diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml deleted file mode 100644 index 3d5b7374..00000000 --- a/docker-compose.prod.yml +++ /dev/null @@ -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: diff --git a/docs/01-system-architecture.md b/docs/01-system-architecture.md index ba932eab..45acf1e8 100644 --- a/docs/01-system-architecture.md +++ b/docs/01-system-architecture.md @@ -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,19 @@ 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` 在本地一键拉起前后端容器和基础服务。 + +单机运行时统一入口: +- `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 +160,4 @@ skillhub/ - 缓存/Session:Spring Session + Redis - 数据库迁移:Flyway - 认证:Spring Security OAuth2 Client(一期 GitHub) +- 镜像发布:GitHub Actions 推送至 GHCR,默认维护 `edge` 与语义化版本标签 diff --git a/docs/09-deployment.md b/docs/09-deployment.md index 21f010ee..120e46ee 100644 --- a/docs/09-deployment.md +++ b/docs/09-deployment.md @@ -1,350 +1,176 @@ # 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 的镜像 + - 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 +- `.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-*` 标签 + +## 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 自带数据库锁 +- 启动策略:应用容器启动时自动执行迁移 From a7481dca5ab712af486fde8538e630168f569d83 Mon Sep 17 00:00:00 2001 From: vsxd Date: Thu, 12 Mar 2026 20:55:51 +0800 Subject: [PATCH 02/38] chore(ci): enable publish workflow on feature branch for validation --- .github/workflows/publish-images.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/publish-images.yml b/.github/workflows/publish-images.yml index 42baa28c..d998d1ab 100644 --- a/.github/workflows/publish-images.yml +++ b/.github/workflows/publish-images.yml @@ -4,6 +4,7 @@ on: push: branches: - main + - feature/project-init tags: - "v*.*.*" workflow_dispatch: From f61da15727d6e5726a56be58cd62c624c4ef8073 Mon Sep 17 00:00:00 2001 From: vsxd Date: Thu, 12 Mar 2026 20:59:44 +0800 Subject: [PATCH 03/38] chore(ci): restore publish workflow triggers --- .github/workflows/publish-images.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/publish-images.yml b/.github/workflows/publish-images.yml index d998d1ab..42baa28c 100644 --- a/.github/workflows/publish-images.yml +++ b/.github/workflows/publish-images.yml @@ -4,7 +4,6 @@ on: push: branches: - main - - feature/project-init tags: - "v*.*.*" workflow_dispatch: From 6305fcca394c978e9c665f774706a4fed1d63e04 Mon Sep 17 00:00:00 2001 From: vsxd Date: Thu, 12 Mar 2026 21:11:41 +0800 Subject: [PATCH 04/38] feat(frontend): redesign UI with Aurora Tech theme - Typography: Playfair Display (hero serif) + Outfit (headings) + DM Sans (body) + JetBrains Mono (code) - Color: light-first with cyan primary + violet accent, deep navy dark theme via .dark class - Visual: dot-grid texture, gradient glow orbs, glassmorphism nav/search, card hover animations, staggered fade-in - Layout: glass header, footer with links, hero with gradient text + CTA buttons - Components: all UI primitives (Button/Card/Input/Tabs/Dialog/Table/Select/Textarea) updated - Pages: all 15+ pages restyled including home, search, detail, login, dashboard, admin --- web/index.html | 3 + web/src/app/layout.tsx | 128 +++++++- web/src/features/auth/login-button.tsx | 9 +- web/src/features/publish/upload-zone.tsx | 43 +-- web/src/features/search/search-bar.tsx | 39 ++- web/src/features/skill/skill-card.tsx | 53 +++- web/src/index.css | 291 +++++++++++++++--- web/src/pages/admin/audit-log.tsx | 14 +- web/src/pages/admin/users.tsx | 20 +- web/src/pages/dashboard.tsx | 25 +- web/src/pages/dashboard/my-namespaces.tsx | 32 +- web/src/pages/dashboard/my-skills.tsx | 44 ++- web/src/pages/dashboard/namespace-members.tsx | 43 ++- web/src/pages/dashboard/publish.tsx | 33 +- web/src/pages/dashboard/review-detail.tsx | 103 ++++--- web/src/pages/dashboard/reviews.tsx | 40 ++- web/src/pages/device.tsx | 19 +- web/src/pages/home.tsx | 90 ++++-- web/src/pages/login.tsx | 25 +- web/src/pages/namespace.tsx | 28 +- web/src/pages/search.tsx | 78 ++--- web/src/pages/skill-detail.tsx | 97 +++--- web/src/shared/components/empty-state.tsx | 23 +- web/src/shared/components/namespace-badge.tsx | 6 +- web/src/shared/components/pagination.tsx | 14 +- web/src/shared/components/skeleton-loader.tsx | 18 +- web/src/shared/ui/button.tsx | 26 +- web/src/shared/ui/card.tsx | 7 +- web/src/shared/ui/dialog.tsx | 6 +- web/src/shared/ui/input.tsx | 2 +- web/src/shared/ui/select.tsx | 2 +- web/src/shared/ui/table.tsx | 6 +- web/src/shared/ui/tabs.tsx | 10 +- web/src/shared/ui/textarea.tsx | 2 +- web/tailwind.config.ts | 43 +++ 35 files changed, 1019 insertions(+), 403 deletions(-) diff --git a/web/index.html b/web/index.html index 3b775782..d4911822 100644 --- a/web/index.html +++ b/web/index.html @@ -4,6 +4,9 @@ SkillHub + + +
diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx index 5685be3f..ac516d8a 100644 --- a/web/src/app/layout.tsx +++ b/web/src/app/layout.tsx @@ -5,31 +5,51 @@ export function Layout() { const { user, isLoading } = useAuth() return ( -
-
-
- - SkillHub +
+ {/* Glow orbs */} +
+
+ + {/* Glass header */} +
+
+ +
+ S +
+ + SkillHub + -
-
+ +
+ + {/* Footer */} +
+
+
+
+
+
+ S +
+ SkillHub +
+

+ 现代化的技能注册中心,为开发者提供高效的技能管理和分发平台。 +

+
+ +
+

快速链接

+
    +
  • + + 首页 + +
  • +
  • + + 搜索技能 + +
  • +
  • + + Dashboard + +
  • +
+
+ +
+

资源

+ +
+
+ +
+

+ © 2024 SkillHub. All rights reserved. +

+ +
+
+
) } diff --git a/web/src/features/auth/login-button.tsx b/web/src/features/auth/login-button.tsx index 57db8e2f..1ddb4812 100644 --- a/web/src/features/auth/login-button.tsx +++ b/web/src/features/auth/login-button.tsx @@ -14,7 +14,8 @@ export function LoginButton() { if (isLoading) { return (
-
@@ -26,11 +27,15 @@ export function LoginButton() { {providers.map((provider) => ( ))} diff --git a/web/src/features/publish/upload-zone.tsx b/web/src/features/publish/upload-zone.tsx index 6ffa4ac3..f9ba9093 100644 --- a/web/src/features/publish/upload-zone.tsx +++ b/web/src/features/publish/upload-zone.tsx @@ -30,32 +30,37 @@ export function UploadZone({ onFileSelect, disabled }: UploadZoneProps) {
-
- - - +
+
+ + + +
{isDragActive ? ( -

放开以上传文件...

+

放开以上传文件...

) : ( <> -

拖拽 ZIP 文件到此处,或点击选择

+

拖拽 ZIP 文件到此处,或点击选择

仅支持 .zip 格式

)} diff --git a/web/src/features/search/search-bar.tsx b/web/src/features/search/search-bar.tsx index ba2f6643..a5c07ee3 100644 --- a/web/src/features/search/search-bar.tsx +++ b/web/src/features/search/search-bar.tsx @@ -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 ( -
- setQuery(e.target.value)} - placeholder={placeholder} - className="flex-1" - /> - + +
+ + + + setQuery(e.target.value)} + placeholder={placeholder} + className="pl-10 border-0 bg-transparent focus-visible:ring-0 focus-visible:ring-offset-0 h-12" + /> +
+
) } diff --git a/web/src/features/skill/skill-card.tsx b/web/src/features/skill/skill-card.tsx index ab52ac80..e688abc5 100644 --- a/web/src/features/skill/skill-card.tsx +++ b/web/src/features/skill/skill-card.tsx @@ -10,28 +10,47 @@ interface SkillCardProps { export function SkillCard({ skill, onClick }: SkillCardProps) { return ( -
-

{skill.displayName}

- -
+ {/* Hover gradient border effect */} +
- {skill.summary && ( -

- {skill.summary} -

- )} +
+
+

+ {skill.displayName} +

+ +
-
- {skill.latestVersion && ( - v{skill.latestVersion} - )} - {skill.downloadCount} 下载 - {skill.ratingAvg !== undefined && skill.ratingCount > 0 && ( - ⭐ {skill.ratingAvg.toFixed(1)} ({skill.ratingCount}) + {skill.summary && ( +

+ {skill.summary} +

)} + +
+ {skill.latestVersion && ( + + v{skill.latestVersion} + + )} + + + + + {skill.downloadCount} + + {skill.ratingAvg !== undefined && skill.ratingCount > 0 && ( + + + + + {skill.ratingAvg.toFixed(1)} ({skill.ratingCount}) + + )} +
) diff --git a/web/src/index.css b/web/src/index.css index 10c2d37f..02742352 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -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; +} diff --git a/web/src/pages/admin/audit-log.tsx b/web/src/pages/admin/audit-log.tsx index 67399088..cca6a177 100644 --- a/web/src/pages/admin/audit-log.tsx +++ b/web/src/pages/admin/audit-log.tsx @@ -30,13 +30,13 @@ export function AuditLogPage() { } return ( -
+
-

审计日志

-

查看系统操作记录

+

审计日志

+

查看系统操作记录

- +
{isLoading ? ( -
加载中...
+
+ {Array.from({ length: 5 }).map((_, i) => ( +
+ ))} +
) : !data || data.items.length === 0 ? (

暂无用户数据

@@ -132,10 +136,10 @@ export function AdminUsersPage() { {user.email} {user.status === 'ACTIVE' ? '活跃' : '已禁用'} diff --git a/web/src/pages/dashboard.tsx b/web/src/pages/dashboard.tsx index 5116fca0..b4358bfb 100644 --- a/web/src/pages/dashboard.tsx +++ b/web/src/pages/dashboard.tsx @@ -6,10 +6,10 @@ export function DashboardPage() { const { user } = useAuth() return ( -
+
-

Dashboard

-

+

Dashboard

+

管理你的账户和 API Tokens

@@ -19,31 +19,32 @@ export function DashboardPage() { 用户信息 你的账户详情 - -
+ +
{user?.avatarUrl && ( {user.displayName} )} -
-
{user?.displayName}
+
+
{user?.displayName}
{user?.email}
-
+
+ 通过 {user?.oauthProvider} 登录
{user?.platformRoles && user.platformRoles.length > 0 && ( -
-
平台角色
+
+
平台角色
{user.platformRoles.map((role) => ( {role} diff --git a/web/src/pages/dashboard/my-namespaces.tsx b/web/src/pages/dashboard/my-namespaces.tsx index cb58340f..d95d7e7b 100644 --- a/web/src/pages/dashboard/my-namespaces.tsx +++ b/web/src/pages/dashboard/my-namespaces.tsx @@ -19,43 +19,51 @@ export function MyNamespacesPage() { } if (isLoading) { - return
加载中...
+ return ( +
+ {Array.from({ length: 3 }).map((_, i) => ( +
+ ))} +
+ ) } return ( -
+
-

我的命名空间

-

管理你的命名空间和团队

+

我的命名空间

+

管理你的命名空间和团队

{namespaces && namespaces.length > 0 ? ( -
- {namespaces.map((namespace) => ( +
+ {namespaces.map((namespace, idx) => ( handleNamespaceClick(namespace.slug)} > -
+
-
-

{namespace.displayName}

+
+

+ {namespace.displayName} +

{namespace.description && ( -

+

{namespace.description}

)} -
@{namespace.slug}
+
@{namespace.slug}
{namespace.type === 'TEAM' && ( diff --git a/web/src/pages/dashboard/my-skills.tsx b/web/src/pages/dashboard/my-skills.tsx index abe5f932..39e3ac4b 100644 --- a/web/src/pages/dashboard/my-skills.tsx +++ b/web/src/pages/dashboard/my-skills.tsx @@ -13,41 +13,59 @@ export function MySkillsPage() { } if (isLoading) { - return
加载中...
+ return ( +
+ {Array.from({ length: 3 }).map((_, i) => ( +
+ ))} +
+ ) } return ( -
+
-

我的技能

-

管理你发布的技能

+

我的技能

+

管理你发布的技能

-
{skills && skills.length > 0 ? (
- {skills.map((skill) => ( + {skills.map((skill, idx) => ( handleSkillClick(skill.namespace, skill.slug)} >
-

{skill.displayName}

+

+ {skill.displayName} +

{skill.summary && ( -

{skill.summary}

+

{skill.summary}

)}
- @{skill.namespace} - {skill.latestVersion && v{skill.latestVersion}} - {skill.downloadCount} 下载 + @{skill.namespace} + {skill.latestVersion && ( + v{skill.latestVersion} + )} + + + + + {skill.downloadCount} +
+ + +
))} @@ -57,7 +75,7 @@ export function MySkillsPage() { title="还没有技能" description="开始发布你的第一个技能吧" action={ - } diff --git a/web/src/pages/dashboard/namespace-members.tsx b/web/src/pages/dashboard/namespace-members.tsx index c1326cc2..b764761b 100644 --- a/web/src/pages/dashboard/namespace-members.tsx +++ b/web/src/pages/dashboard/namespace-members.tsx @@ -11,43 +11,56 @@ export function NamespaceMembersPage() { const { data: members, isLoading: isLoadingMembers } = useNamespaceMembers(slug) if (isLoadingNamespace) { - return
加载中...
+ return ( +
+
+
+
+ ) } if (!namespace) { - return
命名空间不存在
+ return ( +
+

命名空间不存在

+
+ ) } return ( -
+
-
+
-

成员管理

+

成员管理

{isLoadingMembers ? ( -
加载中...
+
+ {Array.from({ length: 3 }).map((_, i) => ( +
+ ))} +
) : members && members.length > 0 ? ( - +
- - - - - + + + + + {members.map((member) => ( - - + + diff --git a/web/src/pages/dashboard/publish.tsx b/web/src/pages/dashboard/publish.tsx index 1303d4b3..2481861a 100644 --- a/web/src/pages/dashboard/publish.tsx +++ b/web/src/pages/dashboard/publish.tsx @@ -36,18 +36,18 @@ export function PublishPage() { } return ( -
+
-

发布技能

-

上传技能包到 SkillHub

+

发布技能

+

上传技能包到 SkillHub

- + {/* Namespace Selector */} -
- +
+ {isLoadingNamespaces ? ( -
加载中...
+
) : ( {/* Upload Zone */} -
- +
+ {selectedFile && ( -
- 已选择: {selectedFile.name} ({(selectedFile.size / 1024).toFixed(1)} KB) +
+ + + + {selectedFile.name} ({(selectedFile.size / 1024).toFixed(1)} KB)
)}
@@ -95,11 +98,11 @@ export function PublishPage() { {/* Publish Button */} + {publishMutation.isPending ? '发布中...' : '确认发布'}
) diff --git a/web/src/pages/dashboard/review-detail.tsx b/web/src/pages/dashboard/review-detail.tsx index b3746dbb..4631b846 100644 --- a/web/src/pages/dashboard/review-detail.tsx +++ b/web/src/pages/dashboard/review-detail.tsx @@ -53,18 +53,27 @@ export function ReviewDetailPage() { } if (isLoading) { - return
加载中...
+ return ( +
+
+
+
+ ) } if (!review) { - return
审核任务不存在
+ return ( +
+

审核任务不存在

+
+ ) } return ( -
+
-

审核详情

+

审核详情

审核 ID: {review.id}

- -
-
- -

{review.skillName}

+ +
+
+ +

{review.skillName}

-
- -

{review.namespace}/{review.skillSlug}

+
+ +

{review.namespace}/{review.skillSlug}

-
- -

{review.version}

-
-
- -

- {review.status === 'PENDING' && '待审核'} - {review.status === 'APPROVED' && '已通过'} - {review.status === 'REJECTED' && '已拒绝'} +

+ +

+ + {review.version} +

-
- -

{review.submittedBy}

+
+ +

+ {review.status === 'PENDING' && ( + 待审核 + )} + {review.status === 'APPROVED' && ( + 已通过 + )} + {review.status === 'REJECTED' && ( + 已拒绝 + )} +

-
- -

{formatDate(review.submittedAt)}

+
+ +

{review.submittedBy}

+
+
+ +

{formatDate(review.submittedAt)}

{review.reviewedBy && ( <> -
- -

{review.reviewedBy}

+
+ +

{review.reviewedBy}

-
- -

- {review.reviewedAt ? formatDate(review.reviewedAt) : '-'} +

+ +

+ {review.reviewedAt ? formatDate(review.reviewedAt) : '—'}

@@ -119,19 +138,19 @@ export function ReviewDetailPage() {
{review.comment && ( -
- -

{review.comment}

+
+ +

{review.comment}

)} {review.status === 'PENDING' && ( - -

审核操作

+ +

审核操作

-
- +
+
用户 ID角色加入时间操作
用户 ID角色加入时间操作
{member.userId}
{member.userId} - + {member.role}