Compare commits
78 Commits
4cc14687e0
...
894c3873a4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
894c3873a4 | ||
|
|
6ba06be47e | ||
|
|
3a1a7b97db | ||
|
|
31e9bb68ac | ||
|
|
c015871024 | ||
|
|
8702312c32 | ||
|
|
c308fc044d | ||
|
|
f4653f2399 | ||
|
|
4322f36a76 | ||
|
|
b737d19166 | ||
|
|
110945e438 | ||
|
|
32bd760b77 | ||
|
|
df4cf55b07 | ||
|
|
826fa1226a | ||
|
|
9981664731 | ||
|
|
e64dc94d29 | ||
|
|
aaf518a66c | ||
|
|
f4397256bd | ||
|
|
a1d245a767 | ||
|
|
a3ecf0c88b | ||
|
|
b8bd0ec545 | ||
|
|
8731c01908 | ||
|
|
12eaa83b87 | ||
|
|
06c08148cb | ||
|
|
18ea3cc355 | ||
|
|
52e1831452 | ||
|
|
8fd6dbb68b | ||
|
|
4c4c33f970 | ||
|
|
2dcb5b3028 | ||
|
|
02a1020f75 | ||
|
|
724858a721 | ||
|
|
e29ef0e76d | ||
|
|
b6832923fa | ||
|
|
18b4864050 | ||
|
|
96ce6fde1c | ||
|
|
1c55cb8559 | ||
|
|
066bb4e83d | ||
|
|
e185885557 | ||
|
|
e6ba5433e2 | ||
|
|
d19a3ca557 | ||
|
|
cac342bdc5 | ||
|
|
8ecd16868c | ||
|
|
8be15cb81e | ||
|
|
dc193a061a | ||
|
|
033cfda6c5 | ||
|
|
b0b33dfd9c | ||
|
|
d63ca39ca4 | ||
|
|
e86803d235 | ||
|
|
b384f92bbf | ||
|
|
9d091d3dfb | ||
|
|
395fa1b498 | ||
|
|
6921220cc2 | ||
|
|
1daab11ba4 | ||
|
|
ac9ffb2a7a | ||
|
|
de85417053 | ||
|
|
0f800da74d | ||
|
|
ec29673d14 | ||
|
|
3b17a0493f | ||
|
|
deb25614ba | ||
|
|
d45e9e28f4 | ||
|
|
129aa3dce7 | ||
|
|
b1ef024724 | ||
|
|
4d5caffe0b | ||
|
|
bf7e6cf0a0 | ||
|
|
fc013b174f | ||
|
|
b560d9ea0f | ||
|
|
065c9e6aa5 | ||
|
|
f082429a58 | ||
|
|
1c81036938 | ||
|
|
1f025ee957 | ||
|
|
7148c8fd39 | ||
|
|
670bcc8c06 | ||
|
|
003f0477f4 | ||
|
|
b8c1dc5958 | ||
|
|
4e2a39a5c0 | ||
|
|
2a7c8f0ff2 | ||
|
|
ba2490dab4 | ||
|
|
14f6e1e500 |
@ -7,6 +7,7 @@ node_modules/
|
|||||||
.env.local
|
.env.local
|
||||||
.env.*.local
|
.env.*.local
|
||||||
|
|
||||||
# Exclude all target/ content, then selectively re-include linux release binaries
|
# Exclude all target/ content, then selectively re-include release binaries
|
||||||
target/
|
target/
|
||||||
|
!target/release/
|
||||||
!target/x86_64-unknown-linux-gnu/release/
|
!target/x86_64-unknown-linux-gnu/release/
|
||||||
|
|||||||
141
.drone.yml
141
.drone.yml
@ -1,141 +0,0 @@
|
|||||||
---
|
|
||||||
# Drone CI Pipeline
|
|
||||||
kind: pipeline
|
|
||||||
type: kubernetes
|
|
||||||
name: default
|
|
||||||
|
|
||||||
clone:
|
|
||||||
disable: true
|
|
||||||
|
|
||||||
trigger:
|
|
||||||
event:
|
|
||||||
- push
|
|
||||||
- tag
|
|
||||||
branch:
|
|
||||||
- main
|
|
||||||
|
|
||||||
environment:
|
|
||||||
REGISTRY: harbor.gitdata.me/gta_team
|
|
||||||
CARGO_TERM_COLOR: always
|
|
||||||
BUILD_TARGET: x86_64-unknown-linux-gnu
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: clone
|
|
||||||
image: bitnami/git:latest
|
|
||||||
commands:
|
|
||||||
- |
|
|
||||||
if [ -n "${DRONE_TAG}" ]; then
|
|
||||||
git checkout ${DRONE_TAG}
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: cargo-build
|
|
||||||
image: rust:1.94-bookworm
|
|
||||||
commands:
|
|
||||||
- cargo fetch
|
|
||||||
- cargo build --release --target ${BUILD_TARGET} -j $(nproc) \
|
|
||||||
--package app \
|
|
||||||
--package gitserver \
|
|
||||||
--package email-server \
|
|
||||||
--package git-hook \
|
|
||||||
--package operator \
|
|
||||||
--package static-server
|
|
||||||
|
|
||||||
- name: docker-build
|
|
||||||
image: gcr.io/kaniko-project/executor:latest
|
|
||||||
environment:
|
|
||||||
DOCKER_CONFIG:
|
|
||||||
from_secret: kaniko_secret
|
|
||||||
commands:
|
|
||||||
- |
|
|
||||||
TAG="${DRONE_TAG:-${DRONE_COMMIT_SHA:0:8}}"
|
|
||||||
echo "==> Building images with tag: ${TAG}"
|
|
||||||
/kaniko/executor --context . --dockerfile docker/app.Dockerfile \
|
|
||||||
--cache --cache-dir target \
|
|
||||||
--destination ${REGISTRY}/app:${TAG} --destination ${REGISTRY}/app:latest
|
|
||||||
/kaniko/executor --context . --dockerfile docker/gitserver.Dockerfile \
|
|
||||||
--cache --cache-dir target \
|
|
||||||
--destination ${REGISTRY}/gitserver:${TAG} --destination ${REGISTRY}/gitserver:latest
|
|
||||||
/kaniko/executor --context . --dockerfile docker/email-worker.Dockerfile \
|
|
||||||
--cache --cache-dir target \
|
|
||||||
--destination ${REGISTRY}/email-worker:${TAG} --destination ${REGISTRY}/email-worker:latest
|
|
||||||
/kaniko/executor --context . --dockerfile docker/git-hook.Dockerfile \
|
|
||||||
--cache --cache-dir target \
|
|
||||||
--destination ${REGISTRY}/git-hook:${TAG} --destination ${REGISTRY}/git-hook:latest
|
|
||||||
/kaniko/executor --context . --dockerfile docker/operator.Dockerfile \
|
|
||||||
--cache --cache-dir target \
|
|
||||||
--destination ${REGISTRY}/operator:${TAG} --destination ${REGISTRY}/operator:latest
|
|
||||||
/kaniko/executor --context . --dockerfile docker/static.Dockerfile \
|
|
||||||
--cache --cache-dir target \
|
|
||||||
--destination ${REGISTRY}/static:${TAG} --destination ${REGISTRY}/static:latest
|
|
||||||
/kaniko/executor --context . --dockerfile docker/frontend.Dockerfile \
|
|
||||||
--destination ${REGISTRY}/frontend:${TAG} --destination ${REGISTRY}/frontend:latest
|
|
||||||
echo "==> All images pushed"
|
|
||||||
depends_on: [ cargo-build ]
|
|
||||||
|
|
||||||
- name: prepare-kubeconfig
|
|
||||||
image: alpine:latest
|
|
||||||
commands:
|
|
||||||
- apk add --no-cache kubectl
|
|
||||||
- mkdir -p ~/.kube
|
|
||||||
- echo "${KUBECONFIG}" | base64 -d > ~/.kube/config
|
|
||||||
- chmod 600 ~/.kube/config
|
|
||||||
|
|
||||||
- name: helm-deploy
|
|
||||||
image: alpine/helm:latest
|
|
||||||
commands:
|
|
||||||
- apk add --no-cache curl kubectl
|
|
||||||
- curl -fsSL -o /tmp/helm.tar.gz https://get.helm.sh/helm-v3.15.0-linux-amd64.tar.gz
|
|
||||||
- tar -xzf /tmp/helm.tar.gz -C /tmp
|
|
||||||
- mv /tmp/linux-amd64/helm /usr/local/bin/helm && chmod +x /usr/local/bin/helm
|
|
||||||
- |
|
|
||||||
TAG="${DRONE_TAG:-${DRONE_COMMIT_SHA:0:8}}"
|
|
||||||
helm upgrade --install gitdata deploy/ \
|
|
||||||
--namespace gitdataai \
|
|
||||||
-f deploy/values.yaml \
|
|
||||||
--set image.registry=${REGISTRY} \
|
|
||||||
--set app.image.tag=${TAG} \
|
|
||||||
--set gitserver.image.tag=${TAG} \
|
|
||||||
--set emailWorker.image.tag=${TAG} \
|
|
||||||
--set gitHook.image.tag=${TAG} \
|
|
||||||
--set operator.image.tag=${TAG} \
|
|
||||||
--set static.image.tag=${TAG} \
|
|
||||||
--set frontend.image.tag=${TAG} \
|
|
||||||
--wait \
|
|
||||||
--timeout 5m \
|
|
||||||
--atomic
|
|
||||||
depends_on: [ docker-build, prepare-kubeconfig ]
|
|
||||||
when:
|
|
||||||
branch: [ main ]
|
|
||||||
|
|
||||||
- name: verify-rollout
|
|
||||||
image: bitnami/kubectl:latest
|
|
||||||
commands:
|
|
||||||
- kubectl rollout status deployment/gitdata-frontend -n gitdataai --timeout=300s
|
|
||||||
- kubectl rollout status deployment/gitdata-app -n gitdataai --timeout=300s
|
|
||||||
- kubectl rollout status deployment/gitdata-gitserver -n gitdataai --timeout=300s
|
|
||||||
- kubectl rollout status deployment/gitdata-email-worker -n gitdataai --timeout=300s
|
|
||||||
- kubectl rollout status deployment/gitdata-git-hook -n gitdataai --timeout=300s
|
|
||||||
depends_on: [ helm-deploy ]
|
|
||||||
when:
|
|
||||||
branch: [ main ]
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# Secrets (register via drone CLI)
|
|
||||||
# =============================================================================
|
|
||||||
# Harbor username
|
|
||||||
# drone secret add --repository <org/repo> --name drone_secret_docker_username --data <username>
|
|
||||||
#
|
|
||||||
# Harbor password
|
|
||||||
# drone secret add --repository <org/repo> --name drone_secret_docker_password --data <password>
|
|
||||||
#
|
|
||||||
# kubeconfig (base64)
|
|
||||||
# drone secret add --repository <org/repo> --name kubeconfig --data "$(cat ~/.kube/config | base64 -w 0)"
|
|
||||||
#
|
|
||||||
# Kaniko dockerconfigjson (for private registry)
|
|
||||||
# drone secret add --repository <org/repo> --name kaniko_secret --data "$(cat ~/.docker/config.json | base64 -w 0)"
|
|
||||||
#
|
|
||||||
# Local exec:
|
|
||||||
# drone exec --trusted \
|
|
||||||
# --secret=DRONE_SECRET_DOCKER_USERNAME=<username> \
|
|
||||||
# --secret=DRONE_SECRET_DOCKER_PASSWORD=<password> \
|
|
||||||
# --secret=KUBECONFIG=$(base64 -w 0 ~/.kube/config)
|
|
||||||
@ -51,9 +51,9 @@ APP_DOMAIN_URL=http://127.0.0.1
|
|||||||
|
|
||||||
# APP_DATABASE_MAX_CONNECTIONS=10
|
# APP_DATABASE_MAX_CONNECTIONS=10
|
||||||
# APP_DATABASE_MIN_CONNECTIONS=2
|
# APP_DATABASE_MIN_CONNECTIONS=2
|
||||||
# APP_DATABASE_IDLE_TIMEOUT=60000
|
# APP_DATABASE_IDLE_TIMEOUT=60000 (milliseconds, default: 60s)
|
||||||
# APP_DATABASE_MAX_LIFETIME=300000
|
# APP_DATABASE_MAX_LIFETIME=300000 (milliseconds, default: 300s)
|
||||||
# APP_DATABASE_CONNECTION_TIMEOUT=5000
|
# APP_DATABASE_CONNECTION_TIMEOUT=5000 (milliseconds, default: 5s)
|
||||||
# APP_DATABASE_REPLICAS=
|
# APP_DATABASE_REPLICAS=
|
||||||
# APP_DATABASE_HEALTH_CHECK_INTERVAL=30
|
# APP_DATABASE_HEALTH_CHECK_INTERVAL=30
|
||||||
# APP_DATABASE_RETRY_ATTEMPTS=3
|
# APP_DATABASE_RETRY_ATTEMPTS=3
|
||||||
|
|||||||
43
.github/pull_request_template.md
vendored
43
.github/pull_request_template.md
vendored
@ -1,43 +0,0 @@
|
|||||||
## Description
|
|
||||||
|
|
||||||
<!-- What does this PR do? What problem does it solve? -->
|
|
||||||
|
|
||||||
## Type of Change
|
|
||||||
|
|
||||||
- [ ] **feat** — New feature
|
|
||||||
- [ ] **fix** — Bug fix
|
|
||||||
- [ ] **docs** — Documentation changes only
|
|
||||||
- [ ] **refactor** — Code refactoring (no functional change)
|
|
||||||
- [ ] **perf** — Performance improvement
|
|
||||||
- [ ] **test** — Adding or updating tests
|
|
||||||
- [ ] **chore** — Build process, auxiliary tools, or CI/CD
|
|
||||||
|
|
||||||
## Related Issue
|
|
||||||
|
|
||||||
<!-- Link to the related issue, e.g., "Closes #123", "Fixes #456" -->
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
<!-- How has this been tested? What cases does it cover? -->
|
|
||||||
|
|
||||||
- [ ] Unit tests added/updated
|
|
||||||
- [ ] Integration tests added/updated
|
|
||||||
- [ ] Manual testing performed
|
|
||||||
|
|
||||||
## Screenshots (if applicable)
|
|
||||||
|
|
||||||
<!-- For UI changes, include before/after screenshots -->
|
|
||||||
|
|
||||||
## Checklist
|
|
||||||
|
|
||||||
- [ ] Code follows the project's coding conventions
|
|
||||||
- [ ] Commit messages follow [Conventional Commits](https://www.conventionalcommits.org/)
|
|
||||||
- [ ] Self-review completed
|
|
||||||
- [ ] Comments added for complex logic
|
|
||||||
- [ ] Documentation updated (if needed)
|
|
||||||
- [ ] No new warnings or errors
|
|
||||||
- [ ] Breaking changes clearly documented
|
|
||||||
|
|
||||||
## Additional Notes
|
|
||||||
|
|
||||||
<!-- Any other context reviewers should know about -->
|
|
||||||
96
.github/workflows/ci.yml
vendored
96
.github/workflows/ci.yml
vendored
@ -1,96 +0,0 @@
|
|||||||
name: CI
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [ main, develop ]
|
|
||||||
pull_request:
|
|
||||||
branches: [ main, develop ]
|
|
||||||
|
|
||||||
env:
|
|
||||||
CARGO_TERM_COLOR: always
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
rust-check:
|
|
||||||
name: Rust Lint & Check
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Install Rust
|
|
||||||
uses: dtolnay/rust@stable
|
|
||||||
with:
|
|
||||||
components: clippy, rustfmt
|
|
||||||
|
|
||||||
- name: Cache cargo
|
|
||||||
uses: Swatinem/rust-cache@v2
|
|
||||||
with:
|
|
||||||
workspaces: apps/* libs/*
|
|
||||||
|
|
||||||
- name: Check formatting
|
|
||||||
run: cargo fmt --workspace -- --check
|
|
||||||
|
|
||||||
- name: Run clippy
|
|
||||||
run: cargo clippy --workspace -- -D warnings
|
|
||||||
|
|
||||||
rust-test:
|
|
||||||
name: Rust Tests
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Install Rust
|
|
||||||
uses: dtolnay/rust@stable
|
|
||||||
|
|
||||||
- name: Cache cargo
|
|
||||||
uses: Swatinem/rust-cache@v2
|
|
||||||
with:
|
|
||||||
workspaces: apps/* libs/*
|
|
||||||
|
|
||||||
- name: Run tests
|
|
||||||
run: cargo test --workspace
|
|
||||||
|
|
||||||
frontend-check:
|
|
||||||
name: Frontend Lint & Type Check
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: '20'
|
|
||||||
cache: 'pnpm'
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: pnpm install --frozen-lockfile
|
|
||||||
|
|
||||||
- name: ESLint
|
|
||||||
run: pnpm lint
|
|
||||||
|
|
||||||
- name: Type check
|
|
||||||
run: pnpm tsc --noEmit
|
|
||||||
|
|
||||||
frontend-build:
|
|
||||||
name: Frontend Build
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: frontend-check
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: '20'
|
|
||||||
cache: 'pnpm'
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: pnpm install --frozen-lockfile
|
|
||||||
|
|
||||||
- name: Build
|
|
||||||
run: pnpm build
|
|
||||||
|
|
||||||
- name: Upload dist
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: dist
|
|
||||||
path: dist/
|
|
||||||
10
.gitignore
vendored
10
.gitignore
vendored
@ -17,3 +17,13 @@ ARCHITECTURE.md
|
|||||||
.agents
|
.agents
|
||||||
.agents.md
|
.agents.md
|
||||||
.next
|
.next
|
||||||
|
node_modules/
|
||||||
|
coverage/
|
||||||
|
.pnpm-store/
|
||||||
|
pnpm-lock.yaml
|
||||||
|
package-lock.json
|
||||||
|
yarn.lock
|
||||||
|
.gemini
|
||||||
|
.omg
|
||||||
|
/.sqry
|
||||||
|
deploy/.server.yaml
|
||||||
11
.mcp.json
Normal file
11
.mcp.json
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"shadcn": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"shadcn@latest",
|
||||||
|
"mcp"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
7
.prettierignore
Normal file
7
.prettierignore
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
node_modules/
|
||||||
|
coverage/
|
||||||
|
.pnpm-store/
|
||||||
|
pnpm-lock.yaml
|
||||||
|
package-lock.json
|
||||||
|
pnpm-lock.yaml
|
||||||
|
yarn.lock
|
||||||
11
.prettierrc
Normal file
11
.prettierrc
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"endOfLine": "lf",
|
||||||
|
"semi": false,
|
||||||
|
"singleQuote": false,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"trailingComma": "es5",
|
||||||
|
"printWidth": 80,
|
||||||
|
"plugins": ["prettier-plugin-tailwindcss"],
|
||||||
|
"tailwindStylesheet": "src/index.css",
|
||||||
|
"tailwindFunctions": ["cn", "cva"]
|
||||||
|
}
|
||||||
2822
Cargo.lock
generated
2822
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
58
Cargo.toml
58
Cargo.toml
@ -1,6 +1,6 @@
|
|||||||
[workspace]
|
[workspace]
|
||||||
members = [
|
members = [
|
||||||
"libs/frontend",
|
# "libs/frontend",
|
||||||
"libs/models",
|
"libs/models",
|
||||||
"libs/session",
|
"libs/session",
|
||||||
"libs/git",
|
"libs/git",
|
||||||
@ -11,30 +11,27 @@ members = [
|
|||||||
"libs/service",
|
"libs/service",
|
||||||
"libs/db",
|
"libs/db",
|
||||||
"libs/api",
|
"libs/api",
|
||||||
"libs/webhook",
|
|
||||||
"libs/transport",
|
"libs/transport",
|
||||||
"libs/rpc",
|
|
||||||
"libs/observability",
|
"libs/observability",
|
||||||
"libs/avatar",
|
"libs/avatar",
|
||||||
"libs/agent",
|
"libs/agent",
|
||||||
"libs/migrate",
|
"libs/migrate",
|
||||||
"libs/agent-tool-derive",
|
|
||||||
"libs/fctool",
|
"libs/fctool",
|
||||||
|
"libs/gingress-proxy",
|
||||||
"apps/migrate",
|
"apps/migrate",
|
||||||
"apps/app",
|
"apps/app",
|
||||||
"apps/adminrpc",
|
|
||||||
"apps/git-hook",
|
"apps/git-hook",
|
||||||
"apps/gitserver",
|
"apps/gitserver",
|
||||||
"apps/email",
|
"apps/email",
|
||||||
"apps/operator",
|
|
||||||
"apps/static",
|
"apps/static",
|
||||||
|
"apps/metrics",
|
||||||
|
"apps/gingress",
|
||||||
]
|
]
|
||||||
|
|
||||||
resolver = "3"
|
resolver = "3"
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
models = { path = "libs/models" }
|
models = { path = "libs/models" }
|
||||||
frontend = { path = "libs/frontend" }
|
|
||||||
session = { path = "libs/session" }
|
session = { path = "libs/session" }
|
||||||
git = { path = "libs/git" }
|
git = { path = "libs/git" }
|
||||||
email = { path = "libs/email" }
|
email = { path = "libs/email" }
|
||||||
@ -45,15 +42,16 @@ service = { path = "libs/service" }
|
|||||||
db = { path = "libs/db" }
|
db = { path = "libs/db" }
|
||||||
api = { path = "libs/api" }
|
api = { path = "libs/api" }
|
||||||
agent = { path = "libs/agent" }
|
agent = { path = "libs/agent" }
|
||||||
webhook = { path = "libs/webhook" }
|
|
||||||
rpc = { path = "libs/rpc" }
|
|
||||||
observability = { path = "libs/observability" }
|
observability = { path = "libs/observability" }
|
||||||
avatar = { path = "libs/avatar" }
|
avatar = { path = "libs/avatar" }
|
||||||
migrate = { path = "libs/migrate" }
|
migrate = { path = "libs/migrate" }
|
||||||
session_manager = { path = "libs/session_manager" }
|
|
||||||
fctool = { path = "libs/fctool" }
|
fctool = { path = "libs/fctool" }
|
||||||
|
transport = { path = "libs/transport" }
|
||||||
|
metrics-aggregator = { path = "apps/metrics" }
|
||||||
|
gingress-proxy = { path = "libs/gingress-proxy" }
|
||||||
|
gingress = { path = "apps/gingress" }
|
||||||
|
|
||||||
sea-query = "1.0.0-rc.31"
|
sea-query = "1.0.0-rc.33"
|
||||||
|
|
||||||
actix-web = "4.13.0"
|
actix-web = "4.13.0"
|
||||||
actix-files = "0.6.10"
|
actix-files = "0.6.10"
|
||||||
@ -64,7 +62,7 @@ actix-multipart = "0.7.2"
|
|||||||
actix-analytics = "1.2.1"
|
actix-analytics = "1.2.1"
|
||||||
actix-jwt-session = "1.0.7"
|
actix-jwt-session = "1.0.7"
|
||||||
actix-csrf = "0.8.0"
|
actix-csrf = "0.8.0"
|
||||||
metrics = "0.22"
|
metrics = "0.24.5"
|
||||||
|
|
||||||
actix-rt = "2.11.0"
|
actix-rt = "2.11.0"
|
||||||
actix = "0.13"
|
actix = "0.13"
|
||||||
@ -97,8 +95,6 @@ futures-util = "0.3.32"
|
|||||||
globset = "0.4.18"
|
globset = "0.4.18"
|
||||||
hex = "0.4.3"
|
hex = "0.4.3"
|
||||||
lettre = { version = "0.11.19", default-features = false, features = ["tokio1-rustls-tls", "smtp-transport", "builder", "pool"] }
|
lettre = { version = "0.11.19", default-features = false, features = ["tokio1-rustls-tls", "smtp-transport", "builder", "pool"] }
|
||||||
kube = { version = "0.98", features = ["derive", "runtime"] }
|
|
||||||
k8s-openapi = { version = "0.24", default-features = false, features = ["v1_28", "schemars"] }
|
|
||||||
mime = "0.3.17"
|
mime = "0.3.17"
|
||||||
mime_guess2 = "2.3.1"
|
mime_guess2 = "2.3.1"
|
||||||
opentelemetry = "0.31.0"
|
opentelemetry = "0.31.0"
|
||||||
@ -110,8 +106,9 @@ prost-build = "0.14.3"
|
|||||||
qdrant-client = "1.17.0"
|
qdrant-client = "1.17.0"
|
||||||
prost-types = "0.14.3"
|
prost-types = "0.14.3"
|
||||||
rand = "0.10.0"
|
rand = "0.10.0"
|
||||||
russh = { version = "0.50.0", default-features = false, features = [] }
|
russh = { version = "0.60.2", default-features = false, features = ["ring", "rsa"] }
|
||||||
hmac = { version = "0.12.1", features = ["std"] }
|
hmac = { version = "0.13" }
|
||||||
|
hkdf = "0.13.0"
|
||||||
sha1_smol = "1.0.1"
|
sha1_smol = "1.0.1"
|
||||||
rsa = { version = "0.9.7", package = "rsa" }
|
rsa = { version = "0.9.7", package = "rsa" }
|
||||||
reqwest = { version = "0.13.2", default-features = false }
|
reqwest = { version = "0.13.2", default-features = false }
|
||||||
@ -120,14 +117,14 @@ dotenvy = "0.15.7"
|
|||||||
# aws-sdk-s3 = "1.127.0"
|
# aws-sdk-s3 = "1.127.0"
|
||||||
sea-orm = "2.0.0-rc.37"
|
sea-orm = "2.0.0-rc.37"
|
||||||
sea-orm-migration = "2.0.0-rc.37"
|
sea-orm-migration = "2.0.0-rc.37"
|
||||||
sha1 = { version = "0.10.6", features = ["compress"] }
|
sha1 = "0.11"
|
||||||
sha2 = "0.11.0-rc.5"
|
sha2 = "0.11"
|
||||||
sysinfo = "0.38.4"
|
sysinfo = "0.39.1"
|
||||||
ssh-key = "0.7.0-rc.9"
|
ssh-key = "0.7.0-rc.9"
|
||||||
tar = "0.4.45"
|
tar = "0.4.45"
|
||||||
zip = "8.3.1"
|
zip = "8.3.1"
|
||||||
tokenizer = "0.1.2"
|
tokenizer = "0.1.2"
|
||||||
tiktoken-rs = "0.9.1"
|
tiktoken-rs = "0.11.0"
|
||||||
regex = "1.12.3"
|
regex = "1.12.3"
|
||||||
jsonwebtoken = "10.3.0"
|
jsonwebtoken = "10.3.0"
|
||||||
once_cell = "1.21.4"
|
once_cell = "1.21.4"
|
||||||
@ -138,7 +135,10 @@ tokio = "1.50.0"
|
|||||||
tokio-util = "0.7.18"
|
tokio-util = "0.7.18"
|
||||||
tokio-stream = "0.1.18"
|
tokio-stream = "0.1.18"
|
||||||
url = "2.5.8"
|
url = "2.5.8"
|
||||||
|
tower = "0.5"
|
||||||
num_cpus = "1.17.0"
|
num_cpus = "1.17.0"
|
||||||
|
ring = "0.17"
|
||||||
|
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] }
|
||||||
clap = "4.6.0"
|
clap = "4.6.0"
|
||||||
time = "0.3.47"
|
time = "0.3.47"
|
||||||
chrono = "0.4.44"
|
chrono = "0.4.44"
|
||||||
@ -159,8 +159,10 @@ pulldown-cmark = "0.12"
|
|||||||
quick-xml = { version = "0.37", features = ["serialize"] }
|
quick-xml = { version = "0.37", features = ["serialize"] }
|
||||||
sqlparser = "0.55"
|
sqlparser = "0.55"
|
||||||
lazy_static = "1.5"
|
lazy_static = "1.5"
|
||||||
|
chacha20poly1305 = "0.10"
|
||||||
md5 = "0.7"
|
md5 = "0.7"
|
||||||
moka = "0.12.15"
|
moka = "0.12.15"
|
||||||
|
dashmap = "7.0.0-rc2"
|
||||||
serde = "1.0.228"
|
serde = "1.0.228"
|
||||||
serde_json = "1.0.149"
|
serde_json = "1.0.149"
|
||||||
serde_yaml = "0.9.33"
|
serde_yaml = "0.9.33"
|
||||||
@ -170,11 +172,19 @@ phf_codegen = "0.13.1"
|
|||||||
base64 = "0.22.1"
|
base64 = "0.22.1"
|
||||||
base64ct = "1"
|
base64ct = "1"
|
||||||
p256 = { version = "0.13", features = ["ecdsa", "std"] }
|
p256 = { version = "0.13", features = ["ecdsa", "std"] }
|
||||||
http = "1"
|
# http version varies per-crate (pingora needs 1.x, actix needs 0.2)
|
||||||
hyper = "0.14"
|
hyper = "0.14"
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
rig-core = "0.30"
|
rig-core = { version = "0.36.0", default-features = false }
|
||||||
|
tokio-tungstenite = { version = "0.29.0", features = [] }
|
||||||
|
async-nats = { version = "0.48.0", features = [] }
|
||||||
|
kube = { version = "3.1.0", features = ["runtime", "derive"] }
|
||||||
|
k8s-openapi = { version = "0.27", features = ["v1_31"] }
|
||||||
|
pingora = { version = "0.8", features = ["proxy"] }
|
||||||
|
pingora-proxy = "0.8"
|
||||||
|
pingora-load-balancing = "0.8"
|
||||||
|
pingora-cache = "0.8"
|
||||||
|
rustls-pemfile = "2"
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.2.9"
|
version = "0.2.9"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
@ -227,4 +237,4 @@ documentation.workspace = true
|
|||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
path = "lib.rs"
|
path = "lib.rs"
|
||||||
crate-type = ["lib"]
|
crate-type = ["lib"]
|
||||||
|
|||||||
264
README.md
264
README.md
@ -1,265 +1,21 @@
|
|||||||
# GitDataAI
|
# React + TypeScript + Vite + shadcn/ui
|
||||||
|
|
||||||
> Where Humans & Agents Engineer Together.
|
This is a template for a new Vite project with React, TypeScript, and shadcn/ui.
|
||||||
>
|
|
||||||
> Every action is a command. Every command is versioned, auditable, and composable.
|
|
||||||
|
|
||||||
## 项目概述
|
## Adding components
|
||||||
|
|
||||||
GitDataAI 是一个面向 Agentic 时代的开发协作平台,通过 **Command as Service** 理念,将人类与 AI 代理的协作标准化。每一次操作——创建仓库、发起 PR、调度 Agent——都是一条可版本化、可回放、可组合的命令。CLI 即 API,工作流即可查询的命令流。
|
To add components to your app, run the following command:
|
||||||
|
|
||||||
### 核心功能
|
|
||||||
|
|
||||||
- **Command as Service** — 所有操作均为版本化命令,CLI 即 API,支持回放与组合
|
|
||||||
- **Git Repositories** — 完整 Git 操作(分支、提交、合并),HTTP/SSH 访问,内置分支保护
|
|
||||||
- **Collaborative Rooms** — 人类与 AI 代理共存的实时命令执行空间
|
|
||||||
- **AI Agents** — 监听命令流,执行 Skill,报告结果,可持久化记忆
|
|
||||||
- **Issue & Pull Request** — 追踪、审查、Agent 自动化
|
|
||||||
- **Skill Registry** — 可复用的 Agent 行为包,通过命令调用
|
|
||||||
|
|
||||||
## 技术栈
|
|
||||||
|
|
||||||
### 后端 (Rust)
|
|
||||||
|
|
||||||
| 类别 | 技术 |
|
|
||||||
|--------|----------------------|
|
|
||||||
| 语言 | Rust 2024 Edition |
|
|
||||||
| Web 框架 | Actix-web |
|
|
||||||
| ORM | SeaORM |
|
|
||||||
| 数据库 | PostgreSQL |
|
|
||||||
| 缓存 | Redis |
|
|
||||||
| 实时通信 | WebSocket (actix-ws) |
|
|
||||||
| 消息队列 | Redis |
|
|
||||||
| 向量数据库 | Qdrant |
|
|
||||||
| Git 操作 | git2 / git2-ext |
|
|
||||||
| 认证 | JWT + Session |
|
|
||||||
| API 文档 | utoipa (OpenAPI) |
|
|
||||||
|
|
||||||
### 前端 (TypeScript/React)
|
|
||||||
|
|
||||||
| 类别 | 技术 |
|
|
||||||
|----------|----------------------------|
|
|
||||||
| 语言 | TypeScript 5.9 |
|
|
||||||
| 框架 | React 19 |
|
|
||||||
| 路由 | React Router v7 |
|
|
||||||
| 构建工具 | Vite 8 + SWC |
|
|
||||||
| UI 组件 | shadcn/ui + Tailwind CSS 4 |
|
|
||||||
| 状态管理 | TanStack Query |
|
|
||||||
| HTTP 客户端 | Axios + OpenAPI 生成 |
|
|
||||||
| Markdown | react-markdown + Shiki |
|
|
||||||
| 拖拽 | dnd-kit |
|
|
||||||
|
|
||||||
## 项目结构
|
|
||||||
|
|
||||||
```
|
|
||||||
code/
|
|
||||||
├── apps/ # 应用程序入口
|
|
||||||
│ ├── app/ # 主 Web 应用
|
|
||||||
│ ├── gitserver/ # Git HTTP/SSH 服务器
|
|
||||||
│ ├── git-hook/ # Git Hook 处理服务
|
|
||||||
│ ├── email/ # 邮件发送服务
|
|
||||||
│ ├── migrate/ # 数据库迁移工具
|
|
||||||
│ └── operator/ # Kubernetes 操作器
|
|
||||||
├── libs/ # 共享库
|
|
||||||
│ ├── api/ # REST API 路由与处理器
|
|
||||||
│ ├── models/ # 数据库模型 (SeaORM)
|
|
||||||
│ ├── service/ # 业务逻辑层
|
|
||||||
│ ├── db/ # 数据库连接池
|
|
||||||
│ ├── config/ # 配置管理
|
|
||||||
│ ├── session/ # 会话管理
|
|
||||||
│ ├── git/ # Git 操作封装
|
|
||||||
│ ├── room/ # 实时聊天服务
|
|
||||||
│ ├── queue/ # 消息队列
|
|
||||||
│ ├── webhook/ # Webhook 处理
|
|
||||||
│ ├── rpc/ # RPC 服务 (gRPC/Tonic)
|
|
||||||
│ ├── email/ # 邮件发送
|
|
||||||
│ ├── agent/ # AI Agent 集成
|
|
||||||
│ ├── avatar/ # 头像处理
|
|
||||||
│ ├── transport/ # 传输层
|
|
||||||
│ └── migrate/ # 迁移脚本
|
|
||||||
├── src/ # 前端源代码
|
|
||||||
│ ├── app/ # 页面路由组件
|
|
||||||
│ ├── components/ # 可复用组件
|
|
||||||
│ ├── contexts/ # React Context
|
|
||||||
│ ├── client/ # API 客户端 (OpenAPI 生成)
|
|
||||||
│ ├── hooks/ # 自定义 Hooks
|
|
||||||
│ └── lib/ # 工具函数
|
|
||||||
├── docker/ # Docker 配置
|
|
||||||
├── scripts/ # 构建脚本
|
|
||||||
├── openapi.json # OpenAPI 规范文件
|
|
||||||
└── Cargo.toml # Rust Workspace 配置
|
|
||||||
```
|
|
||||||
|
|
||||||
## 快速开始
|
|
||||||
|
|
||||||
### 环境要求
|
|
||||||
|
|
||||||
- **Rust**: 最新稳定版 (Edition 2024)
|
|
||||||
- **Node.js**: >= 20
|
|
||||||
- **pnpm**: >= 10
|
|
||||||
- **PostgreSQL**: >= 14
|
|
||||||
- **Redis**: >= 6
|
|
||||||
|
|
||||||
### 安装步骤
|
|
||||||
|
|
||||||
1. **克隆仓库**
|
|
||||||
```bash
|
|
||||||
git clone <repository-url>
|
|
||||||
cd code
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **配置环境变量**
|
|
||||||
```bash
|
|
||||||
cp .env.example .env
|
|
||||||
# 编辑 .env 文件,配置数据库连接等信息
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **启动数据库与 Redis**
|
|
||||||
```bash
|
|
||||||
# 使用 Docker 启动(推荐)
|
|
||||||
docker compose -f docker/docker-compose.yml up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **数据库迁移**
|
|
||||||
```bash
|
|
||||||
cargo run -p migrate
|
|
||||||
```
|
|
||||||
|
|
||||||
5. **启动后端服务**
|
|
||||||
```bash
|
|
||||||
cargo run -p app
|
|
||||||
```
|
|
||||||
|
|
||||||
6. **启动前端开发服务器**
|
|
||||||
```bash
|
|
||||||
pnpm install
|
|
||||||
pnpm dev
|
|
||||||
```
|
|
||||||
|
|
||||||
7. **访问应用**
|
|
||||||
- 前端: http://localhost:5173
|
|
||||||
- 后端 API: http://localhost:8080
|
|
||||||
|
|
||||||
## 开发指南
|
|
||||||
|
|
||||||
### 后端开发
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 运行所有测试
|
npx shadcn@latest add button
|
||||||
cargo test
|
|
||||||
|
|
||||||
# 运行特定模块测试
|
|
||||||
cargo test -p service
|
|
||||||
|
|
||||||
# 检查代码质量
|
|
||||||
cargo clippy --workspace
|
|
||||||
|
|
||||||
# 格式化代码
|
|
||||||
cargo fmt --workspace
|
|
||||||
|
|
||||||
# 生成 OpenAPI 文档
|
|
||||||
pnpm openapi:gen-json
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 前端开发
|
This will place the ui components in the `src/components` directory.
|
||||||
|
|
||||||
```bash
|
## Using components
|
||||||
# 安装依赖
|
|
||||||
pnpm install
|
|
||||||
|
|
||||||
# 启动开发服务器
|
To use the components in your app, import them as follows:
|
||||||
pnpm dev
|
|
||||||
|
|
||||||
# 构建生产版本
|
```tsx
|
||||||
pnpm build
|
import { Button } from "@/components/ui/button"
|
||||||
|
|
||||||
# 代码检查
|
|
||||||
pnpm lint
|
|
||||||
|
|
||||||
# 生成 OpenAPI 客户端
|
|
||||||
pnpm openapi:gen
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 数据库迁移
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 创建新迁移
|
|
||||||
cd libs/migrate && cargo run -- create <migration_name>
|
|
||||||
|
|
||||||
# 执行迁移
|
|
||||||
cargo run -p migrate
|
|
||||||
```
|
|
||||||
|
|
||||||
## 配置说明
|
|
||||||
|
|
||||||
### 必需配置项
|
|
||||||
|
|
||||||
| 变量名 | 说明 | 示例 |
|
|
||||||
|--------------------|---------------|---------------------------------------|
|
|
||||||
| `APP_DATABASE_URL` | PostgreSQL 连接 | `postgresql://user:pass@localhost/db` |
|
|
||||||
| `APP_REDIS_URL` | Redis 连接 | `redis://localhost:6379` |
|
|
||||||
| `APP_AI_API_KEY` | AI 服务 API Key | `sk-xxxxx` |
|
|
||||||
| `APP_SMTP_*` | SMTP 邮件配置 | 见 `.env.example` |
|
|
||||||
|
|
||||||
### 可选配置项
|
|
||||||
|
|
||||||
| 变量名 | 默认值 | 说明 |
|
|
||||||
|--------------------------------|-------------|------------|
|
|
||||||
| `APP_DATABASE_MAX_CONNECTIONS` | 10 | 数据库连接池大小 |
|
|
||||||
| `APP_LOG_LEVEL` | info | 日志级别 |
|
|
||||||
| `APP_QDRANT_URL` | - | 向量数据库地址 |
|
|
||||||
| `APP_REPOS_ROOT` | /data/repos | Git 仓库存储路径 |
|
|
||||||
|
|
||||||
完整配置请参考 `.env.example`。
|
|
||||||
|
|
||||||
## API 文档
|
|
||||||
|
|
||||||
启动服务后访问 http://localhost:8080/swagger-ui 查看完整的 API 文档。
|
|
||||||
|
|
||||||
## 架构设计
|
|
||||||
|
|
||||||
### 后端分层架构
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────┐
|
|
||||||
│ apps/app │ ← 应用入口
|
|
||||||
├─────────────────────────────────────┤
|
|
||||||
│ libs/api │ ← HTTP 路由/Handler
|
|
||||||
├─────────────────────────────────────┤
|
|
||||||
│ libs/service │ ← 业务逻辑层
|
|
||||||
├─────────────────────────────────────┤
|
|
||||||
│ libs/models │ libs/db │ libs/git│ ← 数据访问层
|
|
||||||
├─────────────────────────────────────┤
|
|
||||||
│ PostgreSQL │ Redis │ Qdrant │ ← 存储层
|
|
||||||
└─────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
### 前端目录结构
|
|
||||||
|
|
||||||
```
|
|
||||||
src/
|
|
||||||
├── app/ # 页面级组件 (按功能模块组织)
|
|
||||||
│ ├── project/ # 项目相关页面 (Issue、Settings)
|
|
||||||
│ ├── repository/ # 仓库相关页面 (PR、代码浏览)
|
|
||||||
│ └── settings/ # 用户设置
|
|
||||||
├── components/ # 可复用组件
|
|
||||||
│ ├── ui/ # 基础 UI 组件 (shadcn)
|
|
||||||
│ ├── project/ # 项目相关组件
|
|
||||||
│ ├── repository/ # 仓库相关组件
|
|
||||||
│ └── room/ # 聊天相关组件
|
|
||||||
├── contexts/ # React Context (用户、聊天室等)
|
|
||||||
├── client/ # OpenAPI 生成的客户端
|
|
||||||
└── lib/ # 工具函数与 Hooks
|
|
||||||
```
|
|
||||||
|
|
||||||
## 任务清单
|
|
||||||
|
|
||||||
项目当前开发任务详见 [task.md](./task.md),按优先级分为:
|
|
||||||
|
|
||||||
- **P0** — 阻塞性问题(核心流程不通)
|
|
||||||
- **P1** — 核心体验(关键功能)
|
|
||||||
- **P2** — 体验优化(增强功能)
|
|
||||||
|
|
||||||
## 许可证
|
|
||||||
|
|
||||||
[待添加]
|
|
||||||
|
|||||||
41
admin/.gitignore
vendored
41
admin/.gitignore
vendored
@ -1,41 +0,0 @@
|
|||||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
|
||||||
|
|
||||||
# dependencies
|
|
||||||
/node_modules
|
|
||||||
/.pnp
|
|
||||||
.pnp.*
|
|
||||||
.yarn/*
|
|
||||||
!.yarn/patches
|
|
||||||
!.yarn/plugins
|
|
||||||
!.yarn/releases
|
|
||||||
!.yarn/versions
|
|
||||||
|
|
||||||
# testing
|
|
||||||
/coverage
|
|
||||||
|
|
||||||
# next.js
|
|
||||||
/.next/
|
|
||||||
/out/
|
|
||||||
|
|
||||||
# production
|
|
||||||
/build
|
|
||||||
|
|
||||||
# misc
|
|
||||||
.DS_Store
|
|
||||||
*.pem
|
|
||||||
|
|
||||||
# debug
|
|
||||||
npm-debug.log*
|
|
||||||
yarn-debug.log*
|
|
||||||
yarn-error.log*
|
|
||||||
.pnpm-debug.log*
|
|
||||||
|
|
||||||
# env files (can opt-in for committing if needed)
|
|
||||||
.env*
|
|
||||||
|
|
||||||
# vercel
|
|
||||||
.vercel
|
|
||||||
|
|
||||||
# typescript
|
|
||||||
*.tsbuildinfo
|
|
||||||
next-env.d.ts
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
<!-- BEGIN:nextjs-agent-rules -->
|
|
||||||
# This is NOT the Next.js you know
|
|
||||||
|
|
||||||
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
|
|
||||||
<!-- END:nextjs-agent-rules -->
|
|
||||||
@ -1 +0,0 @@
|
|||||||
@AGENTS.md
|
|
||||||
@ -1,50 +0,0 @@
|
|||||||
# =============================================================================
|
|
||||||
# Stage 1: Build
|
|
||||||
# =============================================================================
|
|
||||||
FROM node:20-alpine AS builder
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Install dependencies first (layer cache optimization)
|
|
||||||
COPY package.json package-lock.json* ./
|
|
||||||
RUN npm ci --legacy-peer-deps
|
|
||||||
|
|
||||||
# Copy source
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
# Build Next.js
|
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
|
||||||
RUN npm run build
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# Stage 2: Runtime (minimal Node.js image)
|
|
||||||
# =============================================================================
|
|
||||||
FROM node:20-alpine AS runtime
|
|
||||||
|
|
||||||
# Install dumb-init for proper signal handling
|
|
||||||
RUN apk add --no-cache dumb-init
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Copy built artifacts from builder
|
|
||||||
COPY --from=builder /app/.next .next
|
|
||||||
COPY --from=builder /app/public ./public
|
|
||||||
COPY --from=builder /app/package.json ./
|
|
||||||
COPY --from=builder /app/node_modules ./node_modules
|
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
|
||||||
|
|
||||||
# Non-root user for security
|
|
||||||
RUN addgroup -g 1001 -S nodejs && \
|
|
||||||
adduser -S nextjs -u 1001
|
|
||||||
USER nextjs
|
|
||||||
|
|
||||||
EXPOSE 3000
|
|
||||||
|
|
||||||
# Health check
|
|
||||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
|
||||||
CMD wget -qO- http://localhost:3000/api/health || exit 1
|
|
||||||
|
|
||||||
ENTRYPOINT ["dumb-init", "--"]
|
|
||||||
CMD ["node_modules/.bin/next", "start","-H", "0.0.0.0"]
|
|
||||||
312
admin/PLAN.md
312
admin/PLAN.md
@ -1,312 +0,0 @@
|
|||||||
# Admin 后台管理系统 — 规划文档
|
|
||||||
|
|
||||||
> **最后更新**: 2026-04-19 (v26) — ROADMAP.md Rust依赖表更新与 PLAN.md 一致;剩余仅平台用户SSO/SAML(Rust) + 多租户(架构);41 tests 通过
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 项目概述
|
|
||||||
|
|
||||||
### 定位
|
|
||||||
|
|
||||||
独立部署的 Next.js 16 Admin 后台管理系统,供平台管理员使用。
|
|
||||||
**与主应用完全独立** — 拥有独立的认证系统、RBAC 数据库表、Redis session 命名空间。
|
|
||||||
|
|
||||||
### 技术栈
|
|
||||||
|
|
||||||
- **Framework**: Next.js 16 (App Router, TypeScript)
|
|
||||||
- **UI**: React 19 + Tailwind CSS + 纯 CSS (黑白极简风格 + dark mode)
|
|
||||||
- **数据库**: PostgreSQL (复用主应用数据库,独立 `admin_*` 表)
|
|
||||||
- **缓存**: Redis Cluster (独立 `admin:session:*` 前缀)
|
|
||||||
- **包管理**: bun
|
|
||||||
|
|
||||||
### 架构特点
|
|
||||||
|
|
||||||
- **独立 Admin 用户体系** ≠ 平台用户
|
|
||||||
- **Session-only** 认证(无 JWT)
|
|
||||||
- **RBAC**: 完整 User / Role / Permission 模型
|
|
||||||
- **审计日志**: 所有操作自动记录
|
|
||||||
- **Redis 集群支持**: SCAN 枚举(无 KEYS)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 目录结构
|
|
||||||
|
|
||||||
```
|
|
||||||
C:\工作\code\admin\
|
|
||||||
├── src/
|
|
||||||
│ ├── app/
|
|
||||||
│ │ ├── layout.tsx # 根布局
|
|
||||||
│ │ ├── page.tsx # 重定向到 /login
|
|
||||||
│ │ ├── login/page.tsx # 登录页
|
|
||||||
│ │ ├── dashboard/page.tsx # 仪表盘(平台统计)
|
|
||||||
│ │ ├── admin/
|
|
||||||
│ │ │ ├── layout.tsx # Admin 布局(侧边栏 + 权限守卫)
|
|
||||||
│ │ │ ├── users/page.tsx # Admin 用户管理
|
|
||||||
│ │ │ ├── users/[id]/page.tsx # Admin 用户详情
|
|
||||||
│ │ │ ├── roles/page.tsx # 角色管理
|
|
||||||
│ │ │ ├── permissions/page.tsx # 权限管理
|
|
||||||
│ │ │ ├── logs/page.tsx # 审计日志
|
|
||||||
│ │ │ ├── sessions/page.tsx # 在线用户管理
|
|
||||||
│ │ │ ├── api-tokens/page.tsx # API Token 管理
|
|
||||||
│ │ │ ├── workspaces/page.tsx # Workspace 列表
|
|
||||||
│ │ │ ├── workspaces/[id]/page.tsx # Workspace 详情
|
|
||||||
│ │ │ ├── projects/page.tsx # 项目列表
|
|
||||||
│ │ │ ├── projects/[id]/page.tsx # 项目详情
|
|
||||||
│ │ │ ├── rooms/page.tsx # 房间列表
|
|
||||||
│ │ │ ├── rooms/[id]/page.tsx # 房间消息
|
|
||||||
│ │ │ ├── repos/page.tsx # 仓库列表
|
|
||||||
│ │ │ └── ai/page.tsx # AI 模型管理
|
|
||||||
│ │ ├── platform/
|
|
||||||
│ │ │ ├── audit/page.tsx # 平台审计日志页面
|
|
||||||
│ │ │ ├── users/page.tsx # 平台用户(只读)
|
|
||||||
│ │ │ └── sessions/page.tsx # 平台用户会话管理
|
|
||||||
│ │ └── api/
|
|
||||||
│ │ ├── auth/login/route.ts # 登录
|
|
||||||
│ │ ├── auth/logout/route.ts # 登出
|
|
||||||
│ │ ├── auth/me/route.ts # 当前会话
|
|
||||||
│ │ ├── auth/oidc/... # OIDC 登录
|
|
||||||
│ │ ├── users/route.ts # Admin 用户 CRUD
|
|
||||||
│ │ ├── users/[id]/route.ts # Admin 用户详情/编辑
|
|
||||||
│ │ ├── roles/route.ts # 角色 CRUD
|
|
||||||
│ │ ├── permissions/route.ts # 权限 CRUD
|
|
||||||
│ │ ├── logs/route.ts # 审计日志查询
|
|
||||||
│ │ ├── sessions/route.ts # 在线用户管理
|
|
||||||
│ │ ├── platform/stats/route.ts # 平台统计
|
|
||||||
│ │ ├── platform/sessions/route.ts # 平台会话管理
|
|
||||||
│ │ ├── platform/users/route.ts # 平台用户列表
|
|
||||||
│ │ ├── platform/workspaces/route.ts # Workspace 列表
|
|
||||||
│ │ ├── platform/workspaces/[id]/route.ts # Workspace 详情
|
|
||||||
│ │ ├── admin/projects/route.ts # 项目列表
|
|
||||||
│ │ ├── admin/projects/[id]/route.ts # 项目详情
|
|
||||||
│ │ ├── platform/rooms/route.ts # 房间列表
|
|
||||||
│ │ ├── platform/rooms/[id]/messages/route.ts # 房间消息
|
|
||||||
│ │ ├── platform/rooms/[id]/messages/[msgId]/route.ts # 撤回消息
|
|
||||||
│ │ ├── platform/workspaces/[id]/alert-config/route.ts # 告警配置 CRUD
|
|
||||||
│ │ ├── platform/repos/route.ts # 仓库列表
|
|
||||||
│ │ ├── platform/activity-stats/route.ts # DAU/MAU 统计
|
|
||||||
│ │ ├── api-tokens/route.ts # API Token CRUD
|
|
||||||
│ │ ├── api-tokens/[id]/route.ts # API Token 删除
|
|
||||||
│ │ ├── platform/audit-logs/route.ts # 平台审计日志
|
|
||||||
│ │ └── platform/ai/route.ts # AI Provider/Model/定价
|
|
||||||
│ ├── lib/
|
|
||||||
│ │ ├── env.ts # 环境变量(所有配置)
|
|
||||||
│ │ ├── db.ts # PostgreSQL 连接池
|
|
||||||
│ │ ├── redis.ts # Redis 客户端(支持集群)
|
|
||||||
│ │ ├── auth.ts # 认证逻辑(登录/OIDC/Session)
|
|
||||||
│ │ ├── rbac.ts # RBAC CRUD(User/Role/Permission)
|
|
||||||
│ │ ├── log.ts # 审计日志
|
|
||||||
│ │ ├── api-token.ts # API Token 管理
|
|
||||||
│ │ └── rbac-middleware.ts # RBAC 中间件(旧,已合并)
|
|
||||||
│ ├── middleware.ts # Next.js 中间件(路由保护 + Bearer Token + API RBAC)
|
|
||||||
│ └── types/
|
|
||||||
│ └── ioredis.d.ts # ioredis 类型声明
|
|
||||||
├── .env.local # 实际配置
|
|
||||||
├── .env.local.example # 配置示例
|
|
||||||
├── package.json
|
|
||||||
├── tsconfig.json
|
|
||||||
├── next.config.ts
|
|
||||||
├── PLAN.md
|
|
||||||
└── ROADMAP.md # 功能演进路线(基于 libs/ 代码库分析)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 功能清单
|
|
||||||
|
|
||||||
### ✅ 已完成
|
|
||||||
|
|
||||||
#### 认证模块
|
|
||||||
|
|
||||||
- [x] 登录页(账号密码)
|
|
||||||
- [x] OIDC 登录(环境变量配置,完整 OIDC/OAuth2 流程 + JWT 验证 + 用户自动注册)
|
|
||||||
- [x] 超级管理员(环境变量配置)
|
|
||||||
- [x] Session 管理(Redis,TTL 可配置)
|
|
||||||
- [x] 登出
|
|
||||||
|
|
||||||
#### 平台概览
|
|
||||||
|
|
||||||
- [x] Dashboard 统计(用户/Workspace/项目/聊天室总数)
|
|
||||||
- [x] 近期注册用户列表
|
|
||||||
- [x] 近期 Workspace 列表
|
|
||||||
- [x] 近期项目列表
|
|
||||||
- [x] Workspace 计划分布统计
|
|
||||||
|
|
||||||
#### Admin 管理
|
|
||||||
|
|
||||||
- [x] Admin 用户 CRUD(分页/搜索)
|
|
||||||
- [x] Admin 用户详情页(角色编辑/密码重置/启用禁用)
|
|
||||||
- [x] 角色 CRUD + 权限分配
|
|
||||||
- [x] 权限 CRUD
|
|
||||||
- [x] 审计日志(分页/筛选/详情弹窗)
|
|
||||||
|
|
||||||
#### 在线用户管理
|
|
||||||
|
|
||||||
- [x] Redis SCAN 枚举会话(`admin:session:*`)
|
|
||||||
- [x] 按用户分组显示
|
|
||||||
- [x] 单会话下线
|
|
||||||
- [x] 用户全部下线
|
|
||||||
- [x] 显示登录时间 / IP / User Agent
|
|
||||||
- [x] 平台用户会话管理(`session:user_uid:*`)
|
|
||||||
- [x] 平台会话下线(单会话/全部下线)
|
|
||||||
|
|
||||||
#### 平台数据(只读)
|
|
||||||
|
|
||||||
- [x] 平台用户列表(分页/搜索)
|
|
||||||
- [x] Workspace 列表(分页/搜索/计划筛选)
|
|
||||||
- [x] Workspace 详情(成员/项目/账单历史)
|
|
||||||
- [x] Workspace 充值(弹窗表单,直写 DB,含事务保护)
|
|
||||||
- [x] Workspace 告警配置(余额不足/月度配额/使用量激增规则配置)
|
|
||||||
- [x] 项目列表(分页/搜索/workspace 筛选/可见性筛选)
|
|
||||||
- [x] 项目详情(成员/账单历史)
|
|
||||||
- [x] 房间列表(分页/搜索/项目筛选/成员数/消息数/最后活跃)
|
|
||||||
- [x] 房间消息详情(消息列表/内容预览/撤回操作)
|
|
||||||
- [x] 仓库列表(分页/搜索/项目筛选/可见性/协作者数/分支数/AI Review 状态)
|
|
||||||
- [x] AI Provider / Model / 版本 / 定价管理(新建/编辑/删除,通过 `POST|PATCH|DELETE /api/admin/ai/providers|models|versions`)
|
|
||||||
|
|
||||||
#### 批量操作
|
|
||||||
|
|
||||||
- [x] 平台用户批量启用/禁用
|
|
||||||
- [x] Workspace 批量调整计划
|
|
||||||
|
|
||||||
#### 集成测试
|
|
||||||
|
|
||||||
- [x] Playwright 测试框架配置(port 3001)
|
|
||||||
- [x] 认证模块测试(登录页表单/重定向/无效凭据/API 认证)
|
|
||||||
- [x] Admin 用户管理 API 测试(CRUD/分页/搜索)
|
|
||||||
- [x] 平台数据 API 测试(统计/用户/Workspace/房间/仓库/活动/审计/会话/项目账单充值)
|
|
||||||
- [x] 中间件权限控制测试(未登录 401/无效 Token 401)
|
|
||||||
|
|
||||||
#### 审计日志
|
|
||||||
|
|
||||||
- [x] 审计日志列表(分页/筛选/详情弹窗)
|
|
||||||
- [x] 审计日志 CSV 导出(携带当前筛选条件)
|
|
||||||
- [x] Admin API Token 管理(创建/删除,Bearer Token 认证)
|
|
||||||
- [x] 平台审计日志(user_activity_log + project_audit_log 合并查询)
|
|
||||||
|
|
||||||
#### RBAC
|
|
||||||
|
|
||||||
- [x] 中间件级别 API 权限控制
|
|
||||||
- [x] 前端布局级路由保护
|
|
||||||
- [x] 16 种默认权限(user:*, role:*, permission:*, log:*, session:*, platform:*)
|
|
||||||
- [x] 超级管理员(`*` 权限)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 数据库设计
|
|
||||||
|
|
||||||
### Admin 表(自主管理)
|
|
||||||
|
|
||||||
| 表名 | 说明 |
|
|
||||||
|-------------------------|----------------|
|
|
||||||
| `admin_user` | 管理员用户 |
|
|
||||||
| `admin_role` | 角色 |
|
|
||||||
| `admin_permission` | 权限(code-based) |
|
|
||||||
| `admin_user_role` | 用户-角色关联 |
|
|
||||||
| `admin_role_permission` | 角色-权限关联 |
|
|
||||||
| `admin_audit_log` | 审计日志 |
|
|
||||||
|
|
||||||
### 平台表(只读查询)
|
|
||||||
|
|
||||||
> **Schema 注意事项**:所有主键均为 UUID(`user.uid`、`workspace.id`、`project.id` 等),不能用整数自增 ID。
|
|
||||||
|
|
||||||
| 表名 | 用途 | 关键列说明 |
|
|
||||||
|-----------------------------|-----------------|-----------|
|
|
||||||
| `user` | 平台用户列表 | PK=`uid` |
|
|
||||||
| `user_password` | 用户密码(is_active)| FK=`user` → `user.uid` |
|
|
||||||
| `workspace` | Workspace 列表/详情 | PK=`id`, `slug`, `deleted_at` |
|
|
||||||
| `workspace_membership` | Workspace 成员 | FK=`workspace_id`, `user_id` (UUID) |
|
|
||||||
| `workspace_billing` | Workspace 余额 | FK=`workspace_id` |
|
|
||||||
| `workspace_billing_history` | 账单历史 | FK=`workspace_id`, `user`; `extra` (jsonb) 为描述字段 |
|
|
||||||
| `project_billing_history` | 项目账单历史 | FK=`project`, `user`; `extra` (jsonb) 为描述字段 |
|
|
||||||
| `project` | 项目列表 | PK=`id`, 无 `slug`/`deleted_at` |
|
|
||||||
| `project_members` | 项目成员 | FK=`project_uuid`, `user_uuid` |
|
|
||||||
| `project_billing` | 项目余额 | FK=`project_uuid` |
|
|
||||||
| `project_billing_history` | 项目账单历史 | FK=`project`, `user` |
|
|
||||||
| `room` | 聊天室 | FK=`project` |
|
|
||||||
| `room_member` | 聊天室成员 | FK=`room`, `user` |
|
|
||||||
| `room_message` | 聊天室消息 | FK=`room`, `sender_id` (UUID) |
|
|
||||||
| `repo` | 仓库 | FK=`project` |
|
|
||||||
| `repo_collaborator` | 仓库协作者 | FK=`repo` |
|
|
||||||
| `ai_model_provider` | AI Provider | 列:`website`, `status` |
|
|
||||||
| `ai_model` | AI 模型 | 无 `model_id`/`enabled` |
|
|
||||||
| `ai_model_version` | AI 模型版本 | FK=`model_id` → `ai_model.id` |
|
|
||||||
| `ai_model_pricing` | AI 定价 | FK=`model_version_id` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Redis 设计
|
|
||||||
|
|
||||||
| Key Pattern | 用途 | TTL |
|
|
||||||
|------------------------|---------------|--------|
|
|
||||||
| `admin:session:<uuid>` | Admin Session | 7 days |
|
|
||||||
|
|
||||||
**Session 结构**(兼容 Rust session 格式):
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"v": 1,
|
|
||||||
"state": {
|
|
||||||
"session:user_uid": -1,
|
|
||||||
"session:username": "admin",
|
|
||||||
"session:roles": [
|
|
||||||
"super_admin"
|
|
||||||
],
|
|
||||||
"session:permissions": [
|
|
||||||
"*"
|
|
||||||
],
|
|
||||||
...
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**约束**:
|
|
||||||
|
|
||||||
- `admin:session:*` 前缀(与业务 `session:user_uid:*` 隔离)
|
|
||||||
- SCAN 枚举(禁止 KEYS)
|
|
||||||
- Pipeline 批量读取
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. 环境变量
|
|
||||||
|
|
||||||
详见 `.env.local.example`。
|
|
||||||
|
|
||||||
关键变量:
|
|
||||||
|
|
||||||
- `DATABASE_URL` — PostgreSQL 连接
|
|
||||||
- `REDIS_CLUSTER_URLS` — Redis 集群节点(逗号分隔)
|
|
||||||
- `ADMIN_SUPER_USERNAME` / `ADMIN_SUPER_PASSWORD` — 超级管理员
|
|
||||||
- `OIDC_ENABLED` / `OIDC_*` — OIDC 配置
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. 安全模型
|
|
||||||
|
|
||||||
- **Admin 认证 ≠ 平台认证** — 完全隔离
|
|
||||||
- **Redis key 前缀区分** — `admin:session:*` vs `session:user_uid:*`
|
|
||||||
- **超级管理员** — 环境变量配置,拥有 `*` 权限
|
|
||||||
- **RBAC 强制** — 中间件层 401/403,返回头 `x-admin-*`
|
|
||||||
- **审计日志** — 所有写操作自动记录
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. 待完成(未来迭代)
|
|
||||||
|
|
||||||
详细路线图见 `ROADMAP.md`。
|
|
||||||
|
|
||||||
- [ ] 平台用户 SSO / SAML(Rust 主应用 OIDC/SAML 中间件,LDAP 集成)
|
|
||||||
- [ ] 多租户隔离架构(独立部署 Admin + 配置同步)
|
|
||||||
|
|
||||||
> **v25 更新 (2026-04-19)**:Playwright 测试补充:项目账单 GET/POST API 测试;41 tests 通过。
|
|
||||||
|
|
||||||
> **v24 更新 (2026-04-19)**:Admin 项目级账单充值完成:新增 `POST /api/admin/projects/[id]/billing`(Next.js 直连 DB + 事务保护),项目详情页新增充值按钮和弹窗表单;ROADMAP.md P1 项目级账单管理 ✅ 完成。
|
|
||||||
|
|
||||||
> **v23 更新 (2026-04-19)**:Admin AI Model CRUD 完成:`libs/api/admin/ai_models.rs` 新增 Provider/Model/版本/定价的创建/编辑/删除 Admin API;`src/app/admin/ai/page.tsx` 新增 CRUD 弹窗 UI;Next.js API 路由 `src/app/api/admin/ai/providers|models|versions/pricing/[id]/route.ts`;`/api/platform/ai` 路由新增版本查询。
|
|
||||||
|
|
||||||
> **v22 更新 (2026-04-19)**:Admin OIDC SSO 完成:`src/lib/auth.ts` 完整 OIDC/OAuth2 流程(`jose` JWT 验证、JWKS 动态公钥获取、用户自动注册/查找);ROADMAP.md 更新:Admin OIDC SSO ✅,平台用户 SSO/SAML 待完成(Rust 后端),多租户待完成。
|
|
||||||
|
|
||||||
> **v21 更新 (2026-04-19)**:告警触发 Rust 后端完成:`libs/service/workspace/alert.rs`(每 30 分钟检查所有 Workspace)、`libs/api/admin/alerts.rs::POST /api/admin/alerts/check`、`workspace_alert_config` SeaORM 模型、后台任务随 `apps/app/src/main.rs` 启动;Admin 前端新增"立即检查告警"按钮;39 tests 通过。
|
|
||||||
|
|
||||||
> **v20 更新 (2026-04-19)**:新增 Rust Admin API(`libs/api/admin/` 模块):`POST /api/admin/ai/sync`(AI 模型同步)和 `POST /api/admin/workspaces/{slug}/add-credit`(充值);Admin 前端新增"同步 OpenRouter 模型"按钮和 `src/app/api/platform/ai/sync/route.ts`;环境变量 `RUST_BACKEND_URL` + `ADMIN_API_SHARED_KEY`;38 tests 通过。
|
|
||||||
|
|
||||||
> **v19 更新 (2026-04-19)**:TypeScript 类型错误修复(Playwright test `ctx` 类型注解 `Awaited<ReturnType<typeof request.newContext>>`),37 tests 全部通过,TypeScript 零错误。Admin Next.js 前端全部完成。
|
|
||||||
@ -1,36 +0,0 @@
|
|||||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
|
||||||
|
|
||||||
## Getting Started
|
|
||||||
|
|
||||||
First, run the development server:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run dev
|
|
||||||
# or
|
|
||||||
yarn dev
|
|
||||||
# or
|
|
||||||
pnpm dev
|
|
||||||
# or
|
|
||||||
bun dev
|
|
||||||
```
|
|
||||||
|
|
||||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
|
||||||
|
|
||||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
|
||||||
|
|
||||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
|
||||||
|
|
||||||
## Learn More
|
|
||||||
|
|
||||||
To learn more about Next.js, take a look at the following resources:
|
|
||||||
|
|
||||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
|
||||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
|
||||||
|
|
||||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
|
||||||
|
|
||||||
## Deploy on Vercel
|
|
||||||
|
|
||||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
|
||||||
|
|
||||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
|
||||||
342
admin/ROADMAP.md
342
admin/ROADMAP.md
@ -1,342 +0,0 @@
|
|||||||
# Admin 后台管理系统 — ROADMAP
|
|
||||||
|
|
||||||
> **最后更新**: 2026-04-19 (v26) — ROADMAP.md Rust依赖表更新:平台会话管理 ✅、审计增强 ✅ 与 PLAN.md 一致;剩余仅平台用户SSO/SAML(Rust) + 多租户(架构);41 tests 通过
|
|
||||||
>
|
|
||||||
> 本文档基于 `C:\工作\code\libs` 代码库分析,规划 Admin 系统的演进路线。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 技术背景
|
|
||||||
|
|
||||||
### 1.1 双应用架构
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────────────────┐
|
|
||||||
│ PostgreSQL (同一数据库) │
|
|
||||||
└─────────────────────────────────────────────────────────┘
|
|
||||||
│ │
|
|
||||||
┌─────┴──────┐ ┌──────┴──────┐
|
|
||||||
│ Rust 主应用 │ │ Next.js Admin │
|
|
||||||
│ (libs/api) │ │ (自包含) │
|
|
||||||
│ Actix-web │ │ Route API │
|
|
||||||
│ libs/svc │ │ libs/rbac │
|
|
||||||
└────────────┘ └──────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
### 1.2 libs/ 代码库结构
|
|
||||||
|
|
||||||
| Crate | 职责 | 与 Admin 的关联 |
|
|
||||||
|-------|------|----------------|
|
|
||||||
| `libs/api` | Actix-web HTTP 路由层 | Admin 可通过 `/api/agents/*` 等接口间接调用 |
|
|
||||||
| `libs/service` | 业务逻辑层 | Workspace 计费/AI 模型同步等核心逻辑所在 |
|
|
||||||
| `libs/models` | SeaORM 实体定义 | Admin DB 查询直接引用相同表结构 |
|
|
||||||
| `libs/session` | 会话管理(Redis) | 主应用使用 `session:user_uid:*`,Admin 使用 `admin:session:*` |
|
|
||||||
| `libs/db` | 数据库连接池 | Admin 直接复用 `DATABASE_URL` |
|
|
||||||
| `libs/agent` | AI Agent(聊天/Review/Summary/ReAct) | Admin 可触发 `sync_upstream_models()` |
|
|
||||||
| `libs/queue` | 异步队列(Redis PubSub) | Email 队列等 |
|
|
||||||
| `libs/email` | SMTP 发送 | 审计通知等 |
|
|
||||||
|
|
||||||
### 1.3 关键 API 路由(主应用 Rust)
|
|
||||||
|
|
||||||
**Workspace 计费**(`libs/api/workspace/billing.rs`):
|
|
||||||
- `GET /api/workspaces/{slug}/billing` — 当前余额/配额
|
|
||||||
- `GET /api/workspaces/{slug}/billing/history` — 账单历史
|
|
||||||
- `POST /api/workspaces/{slug}/billing/add-credit` — 充值(需要 workspace 成员权限)
|
|
||||||
|
|
||||||
**AI 模型管理**(`libs/api/agent/`):
|
|
||||||
- `GET/POST /api/agents/providers` — Provider CRUD
|
|
||||||
- `GET/POST /api/agents/models` — Model CRUD
|
|
||||||
- `GET/POST /api/agents/versions` — 版本管理
|
|
||||||
- `POST /api/agents/versions/{id}/pricing` — 定价设置
|
|
||||||
- `POST /api/agents/models/{id}/sync` — 同步上游模型
|
|
||||||
- `POST /api/agents/code-review/{ns}/{repo}` — 触发 Code Review
|
|
||||||
- `POST /api/agents/pr-description/{ns}/{repo}` — 触发 PR Summary
|
|
||||||
|
|
||||||
**Project**(`libs/api/project/`):
|
|
||||||
- `GET/POST /api/projects/{namespace}/{name}` — Project CRUD
|
|
||||||
- `GET /api/projects/{namespace}/{name}/billing` — 项目级计费
|
|
||||||
|
|
||||||
**其他**:
|
|
||||||
- `libs/service/agent/sync.rs` — `sync_upstream_models()` 从 OpenRouter 同步模型
|
|
||||||
- `libs/service/agent/billing.rs` — `record_ai_usage()` 记录 token 消耗并扣费
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 功能路线图
|
|
||||||
|
|
||||||
### Phase 1: 补全当前功能(短期)
|
|
||||||
|
|
||||||
> 基于现有 libs/api 直接可调用接口,快速实现。
|
|
||||||
|
|
||||||
#### [x] Workspace 计费操作(充值/调整配额)✅
|
|
||||||
**现状**: 已实现(直接写 DB)
|
|
||||||
- `POST /api/platform/workspaces/{id}/add-credit` — 充值 API(INSERT billing_history + UPDATE billing.balance,事务保护)
|
|
||||||
- `/admin/workspaces/[id]/page.tsx` — 充值弹窗(输入金额+备注,充值后自动刷新)
|
|
||||||
- **说明**: 直接写 DB,绕过 Rust service 层(事务内保证原子性)
|
|
||||||
|
|
||||||
#### [x] AI 模型同步触发器 ✅
|
|
||||||
**现状**: 已完成
|
|
||||||
- `libs/api/admin/sync.rs` — `POST /api/admin/ai/sync`(`x-admin-api-key` 认证)
|
|
||||||
- `libs/service/agent/sync.rs::sync_upstream_models` — 已直接调用
|
|
||||||
- `src/app/api/platform/ai/sync/route.ts` — Admin 前端 API 路由(调用 Rust)
|
|
||||||
- `src/app/admin/ai/page.tsx` — "同步 OpenRouter 模型"按钮 + 结果反馈
|
|
||||||
- 环境变量:`RUST_BACKEND_URL` + `ADMIN_API_SHARED_KEY`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Phase 2: 平台级管控能力(中期)
|
|
||||||
|
|
||||||
#### [x] 仓库(Repository)管理视图 ✅
|
|
||||||
**现状**: 已实现
|
|
||||||
- `/admin/repos/page.tsx` — 仓库列表(分页/搜索/仓库名/项目/可见性/协作者数/分支数/AI Review 状态)
|
|
||||||
- `GET /api/platform/repos` — 仓库列表 API
|
|
||||||
|
|
||||||
#### [x] 房间(Room)管理视图 ✅
|
|
||||||
**现状**: 已实现
|
|
||||||
- `/admin/rooms/page.tsx` — 房间列表(按项目筛选/搜索/成员数/消息数/最后活跃)
|
|
||||||
- `/admin/rooms/[id]/page.tsx` — 房间消息详情(消息列表/撤回操作)
|
|
||||||
- `GET /api/platform/rooms` — 房间列表 API
|
|
||||||
- `GET /api/platform/rooms/[id]/messages` — 房间消息 API
|
|
||||||
- `DELETE /api/platform/rooms/[id]/messages/[msgId]` — 撤回消息(admin session 认证)
|
|
||||||
|
|
||||||
#### [x] 项目(Project)管理视图 ✅
|
|
||||||
**现状**: 已实现
|
|
||||||
- `/admin/projects/page.tsx` — 项目列表(分页/搜索/workspace 筛选/可见性筛选)
|
|
||||||
- `/admin/projects/[id]/page.tsx` — 项目详情(成员/账单历史)
|
|
||||||
- `/api/admin/projects/route.ts` — 项目列表 API
|
|
||||||
- `/api/admin/projects/[id]/route.ts` — 项目详情 API
|
|
||||||
**现状**: Workspace 下有项目,但 Admin 无项目列表
|
|
||||||
|
|
||||||
新增:
|
|
||||||
- `/admin/projects/page.tsx` — 项目列表(分页/搜索/workspace 筛选)
|
|
||||||
- `/admin/projects/[id]/page.tsx` — 项目详情(成员/账单/仓库)
|
|
||||||
|
|
||||||
#### [x] 平台用户会话管理 ✅
|
|
||||||
**现状**: 已实现
|
|
||||||
- `/platform/sessions/page.tsx` — 扫描 `session:user_uid:*` Redis 前缀
|
|
||||||
- 按用户分组显示(用户名/UID/IP/UserAgent/登录时间)
|
|
||||||
- 单会话下线 / 用户全部下线
|
|
||||||
- `/api/platform/sessions/route.ts` — 平台会话 API
|
|
||||||
- RBAC: `platform:read` (GET) / `platform:manage` (DELETE)
|
|
||||||
|
|
||||||
#### [x] 审计日志导出(CSV/Excel) ✅
|
|
||||||
**现状**: 已实现
|
|
||||||
- `GET /api/logs?format=csv` — 导出 CSV(含当前筛选条件,最多 10,000 条)
|
|
||||||
- `/admin/logs/page.tsx` — "导出 CSV" 按钮,自动携带当前筛选参数
|
|
||||||
|
|
||||||
#### [x] 批量操作 ✅
|
|
||||||
**现状**: 已实现
|
|
||||||
- 平台用户:批量启用/禁用(`PATCH /api/platform/users`,更新 `user_password.is_active`)
|
|
||||||
- Workspace:批量调整计划(`PATCH /api/platform/workspaces`,UPDATE `workspace.plan`)
|
|
||||||
- 全选/反选/已选计数 UI
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Phase 3: 安全与合规(中期)
|
|
||||||
|
|
||||||
#### [x] Admin API 令牌 ✅
|
|
||||||
**现状**: 已实现
|
|
||||||
- `src/lib/api-token.ts` — Token 生成(SHA-256 哈希)、验证、创建、删除
|
|
||||||
- `admin_api_token` 表(id, name, token_hash, token_prefix, permissions, created_by, expires_at, last_used_at, is_active)
|
|
||||||
- `POST /api/api-tokens` — 创建 Token(返回明文,仅此一次)
|
|
||||||
- `GET /api/api-tokens` — 列出 Token(不返回 hash)
|
|
||||||
- `DELETE /api/api-tokens/[id]` — 删除 Token
|
|
||||||
- `/admin/api-tokens/page.tsx` — Token 管理页面(创建弹窗含权限选择,到期日)
|
|
||||||
- `middleware.ts` — Bearer Token 认证优先于 Session(`x-admin-auth-type: token` header)
|
|
||||||
- RBAC: Token 管理页面需要 session(不允许 token 访问自己)
|
|
||||||
|
|
||||||
#### [x] 平台级审计日志 ✅
|
|
||||||
**现状**: 已实现
|
|
||||||
- `GET /api/platform/audit-logs` — 查询 `user_activity_log` + `project_audit_log`,按时间合并排序
|
|
||||||
- 支持 `source` 筛选(all/user/project)和 `action` 搜索(ILIKE)
|
|
||||||
- `/platform/audit/page.tsx` — 平台审计页面(来源标签/操作类型/用户UID/IP)
|
|
||||||
|
|
||||||
#### [x] Admin OIDC SSO ✅
|
|
||||||
**现状**: 已完成(Admin 前端 Next.js)
|
|
||||||
- `src/lib/auth.ts` — 完整 OIDC/OAuth2 流程实现
|
|
||||||
- `jose` 库 JWT 验证(JWKS 端点动态获取公钥)
|
|
||||||
- `buildOidcAuthUrl()` — 构造授权 URL(`openid profile email` scopes)
|
|
||||||
- `exchangeOidcCode()` — Token 交换 + JWT 验证 + 用户自动创建/查找
|
|
||||||
- `/api/auth/oidc/authorize` — OIDC 授权重定向
|
|
||||||
- `/api/auth/oidc/callback` — 授权回调处理
|
|
||||||
- 审计日志记录 OIDC 登录
|
|
||||||
- 环境变量:`OIDC_ENABLED` / `OIDC_ISSUER` / `OIDC_CLIENT_ID` / `OIDC_CLIENT_SECRET` / `OIDC_REDIRECT_URI`
|
|
||||||
|
|
||||||
#### [ ] 平台用户 SSO / SAML(Rust 后端)
|
|
||||||
**现状**: 平台用户(Rust 主应用)尚未支持 SSO
|
|
||||||
- 需要在 Rust Actix-web 中实现 OIDC/SAML 中间件
|
|
||||||
- 需要用户自动注册/登录流程
|
|
||||||
- 企业级需求(LDAP / SAML 2.0)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Phase 4: 平台洞察与自动化(长期)
|
|
||||||
|
|
||||||
#### [x] 平台使用分析仪表盘 ✅
|
|
||||||
**现状**: 已实现
|
|
||||||
- `/dashboard` — 新增聊天室总数统计
|
|
||||||
- `/dashboard` — Workspace 计划分布(饼图替代文字,数量+百分比)
|
|
||||||
- `/dashboard` — 近期项目列表
|
|
||||||
- `GET /api/platform/stats` — 新增 roomCount, planDistribution, recentProjects
|
|
||||||
- `/dashboard` — DAU 趋势图(近30天 SVG 折线图,MAU/总登录/近24h 统计)
|
|
||||||
- `GET /api/platform/activity-stats` — DAU/MAU 统计 API
|
|
||||||
|
|
||||||
#### [x] 告警与自动化规则 ✅
|
|
||||||
**现状**: 已完成
|
|
||||||
- `workspace_alert_config` 模型(`libs/models/workspaces/workspace_alert_config.rs`)
|
|
||||||
- `libs/service/workspace/alert.rs::check_billing_alerts()` — 核心检查逻辑
|
|
||||||
- 后台任务 `start_billing_alert_task()` — 每 30 分钟自动检查所有 Workspace
|
|
||||||
- `POST /api/admin/alerts/check` — Admin API 手动触发检查
|
|
||||||
- `src/app/api/platform/alerts/check/route.ts` — Admin 前端 API 路由
|
|
||||||
- Workspace 详情页「告警配置」Tab — "立即检查告警"按钮 + 结果反馈
|
|
||||||
- 支持三种告警类型:`low_balance`(余额不足)/ `monthly_quota`(配额超标)/ `usage_surge`(用量激增)
|
|
||||||
- 邮件发送给 Workspace owner/admin,检测用户通知偏好设置
|
|
||||||
- `apps/app/src/main.rs` — 后台任务随应用启动
|
|
||||||
|
|
||||||
#### [ ] 多租户隔离架构
|
|
||||||
**现状**: 长期目标
|
|
||||||
- 支持独立部署的 Admin(当前是单实例)
|
|
||||||
- Admin 配置同步(多集群场景)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 依赖 Rust 主应用的功能
|
|
||||||
|
|
||||||
以下功能需要 Rust 后端支持或新增接口:
|
|
||||||
|
|
||||||
| 功能 | Rust 依赖 | 优先级 |
|
|
||||||
|------|---------|--------|
|
|
||||||
| Workspace 充值 | `libs/api/admin/billing.rs::admin_workspace_add_credit` ✅ 已完成 | P0 ✅ |
|
|
||||||
| AI 模型同步触发 | `libs/api/admin/sync.rs::admin_sync_models` ✅ 已完成 | P0 ✅ |
|
|
||||||
| 告警触发逻辑 | `libs/service/workspace/alert.rs` + 后台任务 ✅ 已完成 | P0 ✅ |
|
|
||||||
| AI 模型 CRUD | `libs/api/admin/ai_models.rs` ✅ Admin CRUD 新建/编辑/删除(Provider/Model/版本/定价) | P1 ✅ |
|
|
||||||
| 项目级账单管理 | `POST /api/admin/projects/[id]/billing` (Next.js 直连 DB,事务保护) ✅ | P1 ✅ |
|
|
||||||
| 平台用户会话管理 | `/platform/sessions/page.tsx` + `session:user_uid:*` Redis 扫描 ✅ | P2 ✅ |
|
|
||||||
| 审计日志增强 | `user_activity_log` + `project_audit_log` 直接查询 ✅ | P2 ✅ |
|
|
||||||
| 平台用户 SSO/SAML | Rust OIDC/SAML 中间件(企业场景) | P2 |
|
|
||||||
| Admin API Token | 已实现(Next.js 直连 DB) | P3 ✅ |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 技术债务与改进
|
|
||||||
|
|
||||||
### 4.1 Admin ↔ 主应用集成模式
|
|
||||||
|
|
||||||
当前问题:Admin 直接查询 DB,绕过 Rust 业务逻辑层。
|
|
||||||
|
|
||||||
两种集成方案:
|
|
||||||
|
|
||||||
**方案 A: Admin 直接写 DB(现状)**
|
|
||||||
- 优点:简单,无跨服务依赖
|
|
||||||
- 缺点:无法复用 service 层逻辑(计费原子性、事务)
|
|
||||||
- 适用:只读操作、非关键数据
|
|
||||||
|
|
||||||
**方案 B: Admin 调用 Rust API(推荐)**
|
|
||||||
- 优点:复用业务逻辑,数据一致性有保障
|
|
||||||
- 缺点:需处理跨应用认证
|
|
||||||
- 适用:计费操作、AI 模型管理等
|
|
||||||
|
|
||||||
**建议**:核心写操作(计费)通过 Rust API,查询类操作可直连 DB。
|
|
||||||
|
|
||||||
### 4.2 跨应用认证问题
|
|
||||||
|
|
||||||
Rust 主应用 API 需要 `session:user_uid` cookie,Admin 无法直接调用。
|
|
||||||
|
|
||||||
解决方案:
|
|
||||||
1. **共享 Session 存储**:Admin 验证后注入 fake session 到 Redis,主应用可读取
|
|
||||||
2. **Admin 专用 API**:Rust 新增 `/api/admin/*` 路由,验证 `admin_session` cookie
|
|
||||||
3. **API Token**:Rust API 支持 Bearer Token,Admin 用服务账号 token 调用
|
|
||||||
|
|
||||||
### 4.3 数据库 Schema 演进
|
|
||||||
|
|
||||||
Admin 表(`admin_*`)与平台表(`workspace`、`user` 等)共存于同一 DB。
|
|
||||||
|
|
||||||
建议:
|
|
||||||
- Admin 表命名统一加前缀 `adm_*`(当前 `admin_*`,兼容)
|
|
||||||
- 避免跨表事务(admin 写操作不影响平台数据,除非明确)
|
|
||||||
|
|
||||||
### 4.4 Redis 命名空间
|
|
||||||
|
|
||||||
| Key Pattern | 用途 |
|
|
||||||
|------------|------|
|
|
||||||
| `session:user_uid:{uuid}` | 平台用户 Session |
|
|
||||||
| `admin:session:{uuid}` | Admin Session |
|
|
||||||
| `user:session:{uuid}` | (别名,同上)|
|
|
||||||
|
|
||||||
注意:平台 session key 实际前缀为 `session:user_uid:`,不是 `user:session:`。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 版本计划
|
|
||||||
|
|
||||||
| 版本 | 目标 | 主要交付 |
|
|
||||||
|------|------|---------|
|
|
||||||
| **v0.1** | MVP(已完成) | 登录/RBAC/审计/在线用户/平台用户/Workspace/AI 模型 |
|
|
||||||
| **v0.2** | 补全(已完成) | 用户详情页 / Workspace 计费操作 / AI 同步触发 |
|
|
||||||
| **v0.3** | 平台级管控(已完成) | 项目管理视图 / 批量操作 / 日志导出 |
|
|
||||||
| **v0.4** | 安全合规(已完成) | API Token ✅ / 平台审计日志 ✅ / SSO/SAML 待完成 |
|
|
||||||
| **v0.5** | 管控增强(已完成) | 房间管理 ✅ / 仓库管理 ✅ / 告警配置 ✅ (UI + Rust 触发) / 多租户待完成 |
|
|
||||||
| **v1.0** | 完整版 ✅ Next.js + Rust Admin API 完成 | Playwright 集成测试 ✅ (41 tests) / AI 同步触发器 ✅ / Workspace 充值 ✅ / 告警触发 ✅ / Admin OIDC SSO ✅ / Admin AI Model CRUD ✅ / 项目充值 ✅ / 平台会话管理 ✅ / 审计增强 ✅ / 平台用户SSO/SAML待完成 / 多租户待完成 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. 附录:libs/ 关键文件索引
|
|
||||||
|
|
||||||
```
|
|
||||||
C:\工作\code\libs\
|
|
||||||
├── api/
|
|
||||||
│ ├── lib.rs # 路由注册入口
|
|
||||||
│ ├── route.rs # web::ServiceConfig 初始化
|
|
||||||
│ ├── auth/ # 认证(登录/注册/OIDC)
|
|
||||||
│ ├── workspace/ # Workspace API
|
|
||||||
│ │ ├── billing.rs # 余额/充值/账单历史
|
|
||||||
│ │ ├── members.rs # 成员管理
|
|
||||||
│ │ ├── info.rs # Workspace 信息
|
|
||||||
│ │ └── stats.rs # 统计
|
|
||||||
│ ├── project/ # Project API
|
|
||||||
│ │ └── billing.rs # 项目级计费
|
|
||||||
│ ├── agent/ # AI 模型/Provider/定价 CRUD
|
|
||||||
│ │ ├── model.rs # 模型 CRUD
|
|
||||||
│ │ ├── provider.rs # Provider CRUD
|
|
||||||
│ │ ├── model_pricing.rs # 定价管理
|
|
||||||
│ │ ├── code_review.rs # Code Review 触发
|
|
||||||
│ │ └── pr_summary.rs # PR Summary 触发
|
|
||||||
│ ├── room/ # 聊天室 WebSocket API
|
|
||||||
│ ├── git/ # Git 操作 API
|
|
||||||
│ ├── issue/ # Issue API
|
|
||||||
│ ├── pull_request/ # PR API
|
|
||||||
│ ├── user/ # 用户设置 API
|
|
||||||
│ └── search/ # 搜索 API
|
|
||||||
├── service/
|
|
||||||
│ ├── lib.rs # AppService 聚合所有 service
|
|
||||||
│ ├── workspace/
|
|
||||||
│ │ ├── billing.rs # workspace_billing_add_credit()
|
|
||||||
│ │ └── mod.rs
|
|
||||||
│ ├── project/
|
|
||||||
│ │ └── billing.rs # 项目级计费
|
|
||||||
│ ├── agent/
|
|
||||||
│ │ ├── billing.rs # record_ai_usage()
|
|
||||||
│ │ ├── sync.rs # sync_upstream_models()
|
|
||||||
│ │ ├── client.rs # AI 客户端(重试/熔断)
|
|
||||||
│ │ ├── tokent.rs # Token 估算
|
|
||||||
│ │ └── react/ # ReAct 模式
|
|
||||||
│ ├── auth/ # 登录/注册/OIDC 逻辑
|
|
||||||
│ ├── user/ # 用户服务
|
|
||||||
│ └── room/ # 聊天室服务
|
|
||||||
├── models/
|
|
||||||
│ ├── users/ # platform user 实体
|
|
||||||
│ ├── workspaces/ # workspace/workspace_membership/workspace_billing
|
|
||||||
│ ├── projects/ # project/project_members/project_billing
|
|
||||||
│ ├── agents/ # ai_model_provider/model/version/pricing
|
|
||||||
│ └── rooms/ # room/room_message/room_member
|
|
||||||
├── session/
|
|
||||||
│ ├── lib.rs # SessionMiddleware
|
|
||||||
│ ├── storage/ # Redis 存储实现
|
|
||||||
│ └── session.rs # Session 结构
|
|
||||||
├── db/
|
|
||||||
│ ├── database.rs # AppDatabase (sqlx pool)
|
|
||||||
│ └── cache.rs # AppCache (Redis cache)
|
|
||||||
└── queue/
|
|
||||||
├── lib.rs # Redis PubSub 队列
|
|
||||||
└── email.rs # Email worker
|
|
||||||
```
|
|
||||||
1153
admin/bun.lock
1153
admin/bun.lock
File diff suppressed because it is too large
Load Diff
@ -1,13 +0,0 @@
|
|||||||
apiVersion: v2
|
|
||||||
name: admin
|
|
||||||
description: GitData.AI Admin Panel (Next.js)
|
|
||||||
type: application
|
|
||||||
version: 0.1.0
|
|
||||||
appVersion: "0.1.0"
|
|
||||||
keywords:
|
|
||||||
- admin
|
|
||||||
- nextjs
|
|
||||||
- gitdata
|
|
||||||
maintainers:
|
|
||||||
- name: gitdata Team
|
|
||||||
email: team@c.dev
|
|
||||||
@ -1,15 +0,0 @@
|
|||||||
{{/* =============================================================================
|
|
||||||
Common helpers
|
|
||||||
============================================================================= */}}
|
|
||||||
|
|
||||||
{{- define "admin.fullname" -}}
|
|
||||||
{{- .Release.Name -}}
|
|
||||||
{{- end -}}
|
|
||||||
|
|
||||||
{{- define "admin.namespace" -}}
|
|
||||||
{{- .Values.namespace | default .Release.Namespace -}}
|
|
||||||
{{- end -}}
|
|
||||||
|
|
||||||
{{- define "admin.image" -}}
|
|
||||||
{{- printf "%s/%s:%s" .Values.image.registry .Values.admin.image.repository .Values.admin.image.tag -}}
|
|
||||||
{{- end -}}
|
|
||||||
@ -1,124 +0,0 @@
|
|||||||
{{- if .Values.admin.enabled -}}
|
|
||||||
apiVersion: apps/v1
|
|
||||||
kind: Deployment
|
|
||||||
metadata:
|
|
||||||
name: {{ include "admin.fullname" . }}-admin
|
|
||||||
namespace: {{ include "admin.namespace" . }}
|
|
||||||
labels:
|
|
||||||
app.kubernetes.io/name: {{ include "admin.fullname" . }}-admin
|
|
||||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
|
||||||
app.kubernetes.io/version: {{ .Chart.AppVersion }}
|
|
||||||
spec:
|
|
||||||
replicas: {{ .Values.admin.replicaCount }}
|
|
||||||
selector:
|
|
||||||
matchLabels:
|
|
||||||
app.kubernetes.io/name: {{ include "admin.fullname" . }}-admin
|
|
||||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
|
||||||
template:
|
|
||||||
metadata:
|
|
||||||
labels:
|
|
||||||
app.kubernetes.io/name: {{ include "admin.fullname" . }}-admin
|
|
||||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
|
||||||
spec:
|
|
||||||
{{- if $.Values.image.pullSecrets }}
|
|
||||||
imagePullSecrets:
|
|
||||||
{{- range $.Values.image.pullSecrets }}
|
|
||||||
- name: {{ . }}
|
|
||||||
{{- end }}
|
|
||||||
{{- end }}
|
|
||||||
terminationGracePeriodSeconds: 30
|
|
||||||
containers:
|
|
||||||
- name: admin
|
|
||||||
image: "{{ include "admin.image" . }}"
|
|
||||||
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
|
||||||
ports:
|
|
||||||
- name: http
|
|
||||||
containerPort: {{ .Values.admin.service.port }}
|
|
||||||
protocol: TCP
|
|
||||||
env:
|
|
||||||
- name: NODE_ENV
|
|
||||||
value: {{ .Values.admin.config.nodeEnv | default "production" | quote }}
|
|
||||||
{{- if .Values.admin.config.appUrl }}
|
|
||||||
- name: NEXT_PUBLIC_APP_URL
|
|
||||||
value: {{ .Values.admin.config.appUrl | quote }}
|
|
||||||
{{- end }}
|
|
||||||
{{- if .Values.admin.config.appDomain }}
|
|
||||||
- name: NEXT_PUBLIC_APP_DOMAIN
|
|
||||||
value: {{ .Values.admin.config.appDomain | quote }}
|
|
||||||
{{- end }}
|
|
||||||
{{- if .Values.secrets.enabled }}
|
|
||||||
- name: {{ .Values.admin.secretKeys.databaseUrl }}
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: {{ include "admin.fullname" . }}-secrets
|
|
||||||
key: {{ .Values.admin.secretKeys.databaseUrl }}
|
|
||||||
- name: {{ .Values.admin.secretKeys.redisUrl }}
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: {{ include "admin.fullname" . }}-secrets
|
|
||||||
key: {{ .Values.admin.secretKeys.redisUrl }}
|
|
||||||
- name: {{ .Values.admin.secretKeys.nextAuthSecret }}
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: {{ include "admin.fullname" . }}-secrets
|
|
||||||
key: {{ .Values.admin.secretKeys.nextAuthSecret }}
|
|
||||||
{{- end }}
|
|
||||||
{{- if kindIs "slice" .Values.admin.env }}
|
|
||||||
{{- range .Values.admin.env }}
|
|
||||||
- name: {{ .name }}
|
|
||||||
value: {{ .value | quote }}
|
|
||||||
{{- end }}
|
|
||||||
{{- else if kindIs "map" .Values.admin.env }}
|
|
||||||
{{- range $k, $v := .Values.admin.env }}
|
|
||||||
- name: {{ $k }}
|
|
||||||
value: {{ $v | quote }}
|
|
||||||
{{- end }}
|
|
||||||
{{- end }}
|
|
||||||
livenessProbe:
|
|
||||||
httpGet:
|
|
||||||
path: {{ .Values.admin.livenessProbe.path }}
|
|
||||||
port: {{ .Values.admin.livenessProbe.port }}
|
|
||||||
initialDelaySeconds: {{ .Values.admin.livenessProbe.initialDelaySeconds }}
|
|
||||||
periodSeconds: {{ .Values.admin.livenessProbe.periodSeconds }}
|
|
||||||
readinessProbe:
|
|
||||||
httpGet:
|
|
||||||
path: {{ .Values.admin.readinessProbe.path }}
|
|
||||||
port: {{ .Values.admin.readinessProbe.port }}
|
|
||||||
initialDelaySeconds: {{ .Values.admin.readinessProbe.initialDelaySeconds }}
|
|
||||||
periodSeconds: {{ .Values.admin.readinessProbe.periodSeconds }}
|
|
||||||
{{- if .Values.admin.resources }}
|
|
||||||
resources:
|
|
||||||
{{- toYaml .Values.admin.resources | nindent 12 }}
|
|
||||||
{{- end }}
|
|
||||||
{{- with .Values.admin.nodeSelector }}
|
|
||||||
nodeSelector:
|
|
||||||
{{- toYaml . | nindent 8 }}
|
|
||||||
{{- end }}
|
|
||||||
{{- with .Values.admin.affinity }}
|
|
||||||
affinity:
|
|
||||||
{{- toYaml . | nindent 8 }}
|
|
||||||
{{- end }}
|
|
||||||
{{- with .Values.admin.tolerations }}
|
|
||||||
tolerations:
|
|
||||||
{{- toYaml . | nindent 8 }}
|
|
||||||
{{- end }}
|
|
||||||
---
|
|
||||||
apiVersion: v1
|
|
||||||
kind: Service
|
|
||||||
metadata:
|
|
||||||
name: {{ include "admin.fullname" . }}-admin
|
|
||||||
namespace: {{ include "admin.namespace" . }}
|
|
||||||
labels:
|
|
||||||
app.kubernetes.io/name: {{ include "admin.fullname" . }}-admin
|
|
||||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
|
||||||
spec:
|
|
||||||
type: {{ .Values.admin.service.type }}
|
|
||||||
ports:
|
|
||||||
- port: {{ .Values.admin.service.port }}
|
|
||||||
targetPort: http
|
|
||||||
protocol: TCP
|
|
||||||
name: http
|
|
||||||
selector:
|
|
||||||
app.kubernetes.io/name: {{ include "admin.fullname" . }}-admin
|
|
||||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
|
||||||
{{- end }}
|
|
||||||
@ -1,58 +0,0 @@
|
|||||||
{{- if and .Values.admin.enabled .Values.admin.ingress.enabled -}}
|
|
||||||
{{- $fullName := include "admin.fullname" . -}}
|
|
||||||
{{- $ns := include "admin.namespace" . -}}
|
|
||||||
{{- $hosts := .Values.admin.ingress.hosts | default list -}}
|
|
||||||
{{- $tlsSecret := .Values.admin.ingress.tlsSecret | default "" -}}
|
|
||||||
{{- $useCertManager := $.Values.certManager.enabled -}}
|
|
||||||
{{- $secretName := "" -}}
|
|
||||||
{{- if ne $tlsSecret "" -}}
|
|
||||||
{{- $secretName = $tlsSecret -}}
|
|
||||||
{{- else if $useCertManager -}}
|
|
||||||
{{- $secretName = printf "%s-admin-tls" $fullName -}}
|
|
||||||
{{- end -}}
|
|
||||||
{{- $tlsEnabled := or $useCertManager (ne $tlsSecret "") -}}
|
|
||||||
{{- $addCertManagerAnnotation := and $useCertManager (eq $tlsSecret "") -}}
|
|
||||||
apiVersion: networking.k8s.io/v1
|
|
||||||
kind: Ingress
|
|
||||||
metadata:
|
|
||||||
name: {{ $fullName }}-admin-ingress
|
|
||||||
namespace: {{ $ns }}
|
|
||||||
labels:
|
|
||||||
app.kubernetes.io/name: {{ $fullName }}-admin
|
|
||||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
|
||||||
annotations:
|
|
||||||
{{- if .Values.admin.ingress.annotations }}
|
|
||||||
{{- toYaml .Values.admin.ingress.annotations | nindent 4 }}
|
|
||||||
{{- end }}
|
|
||||||
{{- if and $addCertManagerAnnotation (not (hasKey (.Values.admin.ingress.annotations | default dict) "cert-manager.io/cluster-issuer")) }}
|
|
||||||
cert-manager.io/cluster-issuer: {{ $.Values.certManager.clusterIssuerName }}
|
|
||||||
{{- end }}
|
|
||||||
nginx.ingress.kubernetes.io/proxy-body-size: "50m"
|
|
||||||
nginx.ingress.kubernetes.io/proxy-http-version: "1.1"
|
|
||||||
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
|
|
||||||
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
|
|
||||||
nginx.ingress.kubernetes.io/enable-websocket: "true"
|
|
||||||
spec:
|
|
||||||
ingressClassName: nginx
|
|
||||||
{{- if and $hosts $tlsEnabled }}
|
|
||||||
tls:
|
|
||||||
{{- range $hosts }}
|
|
||||||
- hosts:
|
|
||||||
- {{ . | toString }}
|
|
||||||
secretName: {{ $secretName }}
|
|
||||||
{{- end }}
|
|
||||||
{{- end }}
|
|
||||||
rules:
|
|
||||||
{{- range $hosts }}
|
|
||||||
- host: {{ . | toString }}
|
|
||||||
http:
|
|
||||||
paths:
|
|
||||||
- path: /
|
|
||||||
pathType: Prefix
|
|
||||||
backend:
|
|
||||||
service:
|
|
||||||
name: {{ $fullName }}-admin
|
|
||||||
port:
|
|
||||||
number: {{ $.Values.admin.service.port }}
|
|
||||||
{{- end }}
|
|
||||||
{{- end }}
|
|
||||||
@ -1,31 +0,0 @@
|
|||||||
{{- /*
|
|
||||||
Bootstrap secrets for development only.
|
|
||||||
In production, use an external secret manager (Vault, SealedSecrets, External Secrets).
|
|
||||||
*/ -}}
|
|
||||||
{{- $secrets := .Values.secrets | default dict -}}
|
|
||||||
{{- if and (ne $secrets.enabled false) $secrets.enabled -}}
|
|
||||||
apiVersion: v1
|
|
||||||
kind: Secret
|
|
||||||
metadata:
|
|
||||||
name: {{ include "admin.fullname" . }}-secrets
|
|
||||||
namespace: {{ include "admin.namespace" . }}
|
|
||||||
labels:
|
|
||||||
app.kubernetes.io/name: {{ .Chart.Name }}
|
|
||||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
|
||||||
annotations:
|
|
||||||
"helm.sh/resource-policy": keep
|
|
||||||
type: Opaque
|
|
||||||
stringData:
|
|
||||||
{{- if $secrets.databaseUrl }}
|
|
||||||
{{ .Values.admin.secretKeys.databaseUrl }}: {{ $secrets.databaseUrl | quote }}
|
|
||||||
{{- end }}
|
|
||||||
{{- if $secrets.redisUrl }}
|
|
||||||
{{ .Values.admin.secretKeys.redisUrl }}: {{ $secrets.redisUrl | quote }}
|
|
||||||
{{- end }}
|
|
||||||
{{- if $secrets.nextAuthSecret }}
|
|
||||||
{{ .Values.admin.secretKeys.nextAuthSecret }}: {{ $secrets.nextAuthSecret | quote }}
|
|
||||||
{{- end }}
|
|
||||||
{{- range $key, $value := $secrets.extra | default dict }}
|
|
||||||
{{ $key }}: {{ $value | quote }}
|
|
||||||
{{- end }}
|
|
||||||
{{- end }}
|
|
||||||
@ -1,44 +0,0 @@
|
|||||||
# User overrides for Helm deployment
|
|
||||||
# Copy to values.user.yaml and customize
|
|
||||||
|
|
||||||
namespace: gitdataai
|
|
||||||
releaseName: admin
|
|
||||||
|
|
||||||
image:
|
|
||||||
registry: harbor.gitdata.me/gta_team
|
|
||||||
pullSecrets:
|
|
||||||
- harbor-secret
|
|
||||||
|
|
||||||
# Admin config
|
|
||||||
admin:
|
|
||||||
replicaCount: 2
|
|
||||||
ingress:
|
|
||||||
enabled: true
|
|
||||||
hosts:
|
|
||||||
- host: admin.gitdata.ai
|
|
||||||
annotations:
|
|
||||||
# nginx.ingress.kubernetes.io/proxy-body-size: "50m"
|
|
||||||
|
|
||||||
config:
|
|
||||||
appUrl: "https://admin.gitdata.ai"
|
|
||||||
appDomain: "admin.gitdata.ai"
|
|
||||||
nodeEnv: production
|
|
||||||
|
|
||||||
resources:
|
|
||||||
requests:
|
|
||||||
cpu: 100m
|
|
||||||
memory: 256Mi
|
|
||||||
limits:
|
|
||||||
cpu: 500m
|
|
||||||
memory: 512Mi
|
|
||||||
|
|
||||||
# Secrets (bootstrap – use external secrets in production)
|
|
||||||
secrets:
|
|
||||||
enabled: true
|
|
||||||
databaseUrl: "postgresql://user:pass@host:5432/gitdata"
|
|
||||||
redisUrl: "redis://host:6379/0"
|
|
||||||
nextAuthSecret: "your-secret-here"
|
|
||||||
|
|
||||||
# Or use external secrets
|
|
||||||
# externalSecrets:
|
|
||||||
# storeName: vault-backend
|
|
||||||
@ -1,95 +0,0 @@
|
|||||||
# =============================================================================
|
|
||||||
# Global / common settings
|
|
||||||
# =============================================================================
|
|
||||||
namespace: gitdataai
|
|
||||||
releaseName: admin
|
|
||||||
|
|
||||||
image:
|
|
||||||
registry: harbor.gitdata.me/gta_team
|
|
||||||
pullPolicy: IfNotPresent
|
|
||||||
pullSecrets: [ ]
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# Cert-Manager Configuration (集群已安装 cert-manager)
|
|
||||||
# =============================================================================
|
|
||||||
certManager:
|
|
||||||
enabled: true
|
|
||||||
clusterIssuerName: cloudflare-acme-cluster-issuer
|
|
||||||
|
|
||||||
externalSecrets:
|
|
||||||
storeName: vault-backend
|
|
||||||
storeKind: SecretStore
|
|
||||||
|
|
||||||
admin:
|
|
||||||
enabled: true
|
|
||||||
replicaCount: 2
|
|
||||||
|
|
||||||
image:
|
|
||||||
repository: admin
|
|
||||||
tag: latest
|
|
||||||
|
|
||||||
service:
|
|
||||||
type: ClusterIP
|
|
||||||
port: 3000
|
|
||||||
|
|
||||||
ingress:
|
|
||||||
enabled: true
|
|
||||||
hosts:
|
|
||||||
- admin.gitdata.me
|
|
||||||
# tlsSecret: my-tls-secret # uncomment to use a pre-existing TLS secret (overrides cert-manager auto-issue)
|
|
||||||
annotations:
|
|
||||||
cert-manager.io/cluster-issuer: cloudflare-acme-cluster-issuer # auto-set by template when certManager.enabled=true
|
|
||||||
kubernetes.io/ingress.class: nginx
|
|
||||||
|
|
||||||
|
|
||||||
resources:
|
|
||||||
requests:
|
|
||||||
cpu: 100m
|
|
||||||
memory: 256Mi
|
|
||||||
|
|
||||||
livenessProbe:
|
|
||||||
path: /api/health
|
|
||||||
port: 3000
|
|
||||||
initialDelaySeconds: 10
|
|
||||||
periodSeconds: 10
|
|
||||||
|
|
||||||
readinessProbe:
|
|
||||||
path: /api/health
|
|
||||||
port: 3000
|
|
||||||
initialDelaySeconds: 5
|
|
||||||
periodSeconds: 5
|
|
||||||
|
|
||||||
config:
|
|
||||||
appUrl: ""
|
|
||||||
appDomain: ""
|
|
||||||
nodeEnv: production
|
|
||||||
|
|
||||||
secretKeys:
|
|
||||||
databaseUrl: APP_DATABASE_URL
|
|
||||||
redisUrl: APP_REDIS_URL
|
|
||||||
nextAuthSecret: APP_NEXTAUTH_SECRET
|
|
||||||
|
|
||||||
env:
|
|
||||||
DATABASE_URL: "postgresql://gitdataai:gitdataai123@cnpg-cluster-rw.cnpg:5432/gitdataai?sslmode=disable"
|
|
||||||
REDIS_CLUSTER_URLS: "redis://default:redis123@valkey-cluster-0.valkey-cluster.valkey-cluster.svc.cluster.local:6379,redis://default:redis123@valkey-cluster-1.valkey-cluster.valkey-cluster.svc.cluster.local:6379,redis://default:redis123@valkey-cluster-2.valkey-cluster.valkey-cluster.svc.cluster.local:6379"
|
|
||||||
REDIS_URL: "redis://default:redis123@valkey-cluster.valkey-cluster.svc.cluster.local:6379"
|
|
||||||
ADMIN_SESSION_COOKIE_NAME: admin_session
|
|
||||||
ADMIN_SESSION_TTL: 604800
|
|
||||||
ADMIN_SUPER_USERNAME: admin
|
|
||||||
ADMIN_SUPER_PASSWORD: admin123
|
|
||||||
COOKIE_SECURE: false
|
|
||||||
COOKIE_SAME_SITE: lax
|
|
||||||
APP_NEXTAUTH_SECRET: ""
|
|
||||||
ADMIN_RPC_URL: gitdata-adminrpc.gitdataai.svc.cluster.local:9090
|
|
||||||
|
|
||||||
|
|
||||||
nodeSelector: { }
|
|
||||||
tolerations: [ ]
|
|
||||||
affinity: { }
|
|
||||||
|
|
||||||
secrets:
|
|
||||||
enabled: false
|
|
||||||
databaseUrl: "postgresql://gitdataai:gitdataai123@cnpg-cluster-rw.cnpg:5432/gitdataai?sslmode=disable"
|
|
||||||
redisUrl: "redis://:redis123@valkey-cluster.valkey-cluster.svc.cluster.local:6379"
|
|
||||||
nextAuthSecret: ""
|
|
||||||
extra: { }
|
|
||||||
@ -1,18 +0,0 @@
|
|||||||
import { defineConfig, globalIgnores } from "eslint/config";
|
|
||||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
|
||||||
import nextTs from "eslint-config-next/typescript";
|
|
||||||
|
|
||||||
const eslintConfig = defineConfig([
|
|
||||||
...nextVitals,
|
|
||||||
...nextTs,
|
|
||||||
// Override default ignores of eslint-config-next.
|
|
||||||
globalIgnores([
|
|
||||||
// Default ignores of eslint-config-next:
|
|
||||||
".next/**",
|
|
||||||
"out/**",
|
|
||||||
"build/**",
|
|
||||||
"next-env.d.ts",
|
|
||||||
]),
|
|
||||||
]);
|
|
||||||
|
|
||||||
export default eslintConfig;
|
|
||||||
278
admin/metrics.md
278
admin/metrics.md
@ -1,278 +0,0 @@
|
|||||||
# Admin 平台指标 — Grafana / Prometheus 配置指南
|
|
||||||
|
|
||||||
## 概述
|
|
||||||
|
|
||||||
Admin 服务暴露两个指标端点:
|
|
||||||
|
|
||||||
| 端点 | 格式 | 用途 |
|
|
||||||
|------|------|------|
|
|
||||||
| `GET /api/metrics` | JSON | 前端页面 / 人工查看 / API 消费 |
|
|
||||||
| `GET /api/metrics/prometheus` | Prometheus Text | Prometheus 采集 |
|
|
||||||
|
|
||||||
Prometheus 端点 **无需认证**,可直接 scrape。
|
|
||||||
|
|
||||||
## 采集的指标
|
|
||||||
|
|
||||||
所有指标通过 `platform_entity_count` Gauge 暴露,带 `entity` 和 `window` 两个 label:
|
|
||||||
|
|
||||||
```
|
|
||||||
# HELP platform_entity_count Platform entity counts by time window
|
|
||||||
# TYPE platform_entity_count gauge
|
|
||||||
platform_entity_count{entity="users",window="total"} 1000
|
|
||||||
platform_entity_count{entity="users",window="27h"} 5
|
|
||||||
platform_entity_count{entity="users",window="7d"} 32
|
|
||||||
platform_entity_count{entity="users",window="30d"} 150
|
|
||||||
platform_entity_count{entity="workspaces",window="total"} 50
|
|
||||||
platform_entity_count{entity="workspaces",window="27h"} 1
|
|
||||||
...
|
|
||||||
platform_entity_count{entity="skills",window="30d"} 45
|
|
||||||
```
|
|
||||||
|
|
||||||
Entity 列表:`users`、`workspaces`、`projects`、`repos`、`rooms`、`skills`
|
|
||||||
|
|
||||||
Window 列表:`total`(累计)、`27h`(近27小时)、`7d`(近7天)、`30d`(近30天)
|
|
||||||
|
|
||||||
## Prometheus 配置
|
|
||||||
|
|
||||||
### prometheus.yml
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
scrape_configs:
|
|
||||||
- job_name: 'admin-metrics'
|
|
||||||
scrape_interval: 60s
|
|
||||||
metrics_path: '/api/metrics/prometheus'
|
|
||||||
static_configs:
|
|
||||||
- targets: ['<admin-host>:<port>']
|
|
||||||
labels:
|
|
||||||
env: 'production'
|
|
||||||
service: 'admin'
|
|
||||||
```
|
|
||||||
|
|
||||||
### K8s ServiceMonitor(如果用 prometheus-operator)
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
apiVersion: monitoring.coreos.com/v1
|
|
||||||
kind: ServiceMonitor
|
|
||||||
metadata:
|
|
||||||
name: admin-metrics
|
|
||||||
namespace: monitoring
|
|
||||||
labels:
|
|
||||||
release: prometheus
|
|
||||||
spec:
|
|
||||||
selector:
|
|
||||||
matchLabels:
|
|
||||||
app: admin
|
|
||||||
endpoints:
|
|
||||||
- port: http
|
|
||||||
path: /api/metrics/prometheus
|
|
||||||
interval: 60s
|
|
||||||
```
|
|
||||||
|
|
||||||
## Grafana Dashboard
|
|
||||||
|
|
||||||
### 推荐 Panel 配置
|
|
||||||
|
|
||||||
#### Panel 1: 实体总量(Stat Panel)
|
|
||||||
|
|
||||||
```
|
|
||||||
Query:
|
|
||||||
platform_entity_count{window="total"}
|
|
||||||
|
|
||||||
Visualization: Stat
|
|
||||||
- Show: Value
|
|
||||||
- Color mode: Background
|
|
||||||
- Thresholds: 按实际业务设定
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Panel 2: 27 小时增长趋势(Time Series / Bar Gauge)
|
|
||||||
|
|
||||||
```
|
|
||||||
Query:
|
|
||||||
platform_entity_count{window="27h"}
|
|
||||||
|
|
||||||
Visualization: Bar Gauge
|
|
||||||
- Display: Basic
|
|
||||||
- Show: Value
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Panel 3: 7 天 / 30 天对比(Bar Chart)
|
|
||||||
|
|
||||||
```
|
|
||||||
Query:
|
|
||||||
platform_entity_count{window=~"7d|30d"}
|
|
||||||
|
|
||||||
Visualization: Bar Chart
|
|
||||||
- Group by: entity
|
|
||||||
- Bar mode: grouped
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Panel 4: 总量汇总表(Table Panel)
|
|
||||||
|
|
||||||
```
|
|
||||||
Query:
|
|
||||||
platform_entity_count
|
|
||||||
|
|
||||||
Transform:
|
|
||||||
1. Labels to fields
|
|
||||||
2. Pivot by entity
|
|
||||||
3. Organize fields
|
|
||||||
|
|
||||||
Visualization: Table
|
|
||||||
```
|
|
||||||
|
|
||||||
### Dashboard JSON 模板
|
|
||||||
|
|
||||||
将以下 JSON 导入 Grafana(Dashboard → Import → Paste JSON):
|
|
||||||
|
|
||||||
> 注:`uid` 和 `datasource` 需要根据实际 Prometheus 数据源修改。
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"dashboard": {
|
|
||||||
"title": "Admin 平台指标",
|
|
||||||
"tags": ["admin", "platform"],
|
|
||||||
"timezone": "browser",
|
|
||||||
"panels": [
|
|
||||||
{
|
|
||||||
"id": 1,
|
|
||||||
"title": "实体总量",
|
|
||||||
"type": "stat",
|
|
||||||
"gridPos": { "h": 4, "w": 24, "x": 0, "y": 0 },
|
|
||||||
"targets": [
|
|
||||||
{
|
|
||||||
"expr": "platform_entity_count{window=\"total\"}",
|
|
||||||
"legendFormat": "{{entity}}"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"options": {
|
|
||||||
"colorMode": "background",
|
|
||||||
"graphMode": "none",
|
|
||||||
"justifyMode": "auto"
|
|
||||||
},
|
|
||||||
"fieldConfig": {
|
|
||||||
"defaults": {
|
|
||||||
"thresholds": {
|
|
||||||
"mode": "absolute",
|
|
||||||
"steps": [
|
|
||||||
{ "color": "green", "value": null },
|
|
||||||
{ "color": "yellow", "value": 100 },
|
|
||||||
{ "color": "red", "value": 1000 }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 2,
|
|
||||||
"title": "近 27 小时新增",
|
|
||||||
"type": "bargauge",
|
|
||||||
"gridPos": { "h": 6, "w": 12, "x": 0, "y": 4 },
|
|
||||||
"targets": [
|
|
||||||
{
|
|
||||||
"expr": "platform_entity_count{window=\"27h\"}",
|
|
||||||
"legendFormat": "{{entity}}"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"options": {
|
|
||||||
"displayMode": "gradient",
|
|
||||||
"orientation": "horizontal"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 3,
|
|
||||||
"title": "近 7 天 / 30 天对比",
|
|
||||||
"type": "barchart",
|
|
||||||
"gridPos": { "h": 6, "w": 12, "x": 12, "y": 4 },
|
|
||||||
"targets": [
|
|
||||||
{
|
|
||||||
"expr": "platform_entity_count{window=~\"7d|30d\"}",
|
|
||||||
"legendFormat": "{{entity}} ({{window}})"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"options": {
|
|
||||||
"barRadius": 0.05,
|
|
||||||
"groupWidth": 0.7,
|
|
||||||
"orientation": "auto"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 4,
|
|
||||||
"title": "指标汇总表",
|
|
||||||
"type": "table",
|
|
||||||
"gridPos": { "h": 8, "w": 24, "x": 0, "y": 10 },
|
|
||||||
"targets": [
|
|
||||||
{
|
|
||||||
"expr": "platform_entity_count",
|
|
||||||
"format": "table",
|
|
||||||
"instant": true
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"transformations": [
|
|
||||||
{ "id": "labelsToFields", "options": {} },
|
|
||||||
{
|
|
||||||
"id": "organize",
|
|
||||||
"options": {
|
|
||||||
"excludeByName": { "Time": true, "__name__": true },
|
|
||||||
"indexByName": { "entity": 0, "window": 1, "Value": 2 }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"options": {
|
|
||||||
"showHeader": true,
|
|
||||||
"sortBy": [{ "desc": false, "displayName": "entity" }]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"time": { "from": "now-24h", "to": "now" },
|
|
||||||
"refresh": "1m"
|
|
||||||
},
|
|
||||||
"overwrite": true
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 告警规则(可选)
|
|
||||||
|
|
||||||
### prometheus rules
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
groups:
|
|
||||||
- name: admin-entity-growth
|
|
||||||
rules:
|
|
||||||
# 27 小时内用户增长超过 100 告警
|
|
||||||
- alert: HighUserGrowth27h
|
|
||||||
expr: platform_entity_count{entity="users", window="27h"} > 100
|
|
||||||
for: 5m
|
|
||||||
labels:
|
|
||||||
severity: warning
|
|
||||||
annotations:
|
|
||||||
summary: "27 小时内新增用户 {{ $value }} 超过阈值"
|
|
||||||
|
|
||||||
# 仓库 7 天零增长告警
|
|
||||||
- alert: NoRepoGrowth7d
|
|
||||||
expr: platform_entity_count{entity="repos", window="7d"} == 0
|
|
||||||
and on() platform_entity_count{entity="repos", window="total"} > 0
|
|
||||||
for: 1h
|
|
||||||
labels:
|
|
||||||
severity: info
|
|
||||||
annotations:
|
|
||||||
summary: "近 7 天无新增仓库"
|
|
||||||
```
|
|
||||||
|
|
||||||
## 验证
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. JSON 格式
|
|
||||||
curl http://localhost:3000/api/metrics | jq .
|
|
||||||
|
|
||||||
# 2. Prometheus 格式
|
|
||||||
curl http://localhost:3000/api/metrics/prometheus
|
|
||||||
|
|
||||||
# 预期输出:
|
|
||||||
# HELP platform_entity_count Platform entity counts by time window
|
|
||||||
# TYPE platform_entity_count gauge
|
|
||||||
platform_entity_count{entity="users",window="27h"} 0
|
|
||||||
platform_entity_count{entity="users",window="30d"} 0
|
|
||||||
platform_entity_count{entity="users",window="7d"} 0
|
|
||||||
platform_entity_count{entity="users",window="total"} 5
|
|
||||||
...
|
|
||||||
```
|
|
||||||
@ -1,10 +0,0 @@
|
|||||||
import type { NextConfig } from "next";
|
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
|
||||||
serverExternalPackages: ["bcrypt", "ioredis"],
|
|
||||||
turbopack: {
|
|
||||||
root: process.cwd(),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export default nextConfig;
|
|
||||||
7349
admin/package-lock.json
generated
7349
admin/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -1,52 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "admin",
|
|
||||||
"version": "0.1.0",
|
|
||||||
"private": true,
|
|
||||||
"scripts": {
|
|
||||||
"dev": "next dev",
|
|
||||||
"build": "next build",
|
|
||||||
"start": "node server.js",
|
|
||||||
"start:original": "next start",
|
|
||||||
"lint": "eslint",
|
|
||||||
"db:migrate": "bun --env-file=.env.local src/lib/db/migrate.ts",
|
|
||||||
"test": "playwright test",
|
|
||||||
"test:ui": "playwright test --ui"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@bufbuild/connect": "^0.13.0",
|
|
||||||
"@bufbuild/protobuf": "^2.11.0",
|
|
||||||
"@types/node-cron": "^3.0.11",
|
|
||||||
"argon2": "^0.44.0",
|
|
||||||
"bcrypt": "^5.1.1",
|
|
||||||
"clsx": "^2.1.1",
|
|
||||||
"date-fns": "^3.3.1",
|
|
||||||
"ioredis": "^5.10.1",
|
|
||||||
"jose": "^5.2.0",
|
|
||||||
"lucide-react": "^0.344.0",
|
|
||||||
"next": "16.2.4",
|
|
||||||
"node-cron": "^4.2.1",
|
|
||||||
"pg": "^8.11.3",
|
|
||||||
"prom-client": "^15.1.3",
|
|
||||||
"react": "19.2.4",
|
|
||||||
"react-dom": "19.2.4",
|
|
||||||
"tailwind-merge": "^2.2.0",
|
|
||||||
"uuid": "^9.0.0",
|
|
||||||
"zod": "^3.22.4"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@playwright/test": "^1.59.1",
|
|
||||||
"@tailwindcss/postcss": "^4",
|
|
||||||
"@types/bcrypt": "^5.0.2",
|
|
||||||
"@types/ioredis": "^5.0.0",
|
|
||||||
"@types/node": "^20",
|
|
||||||
"@types/pg": "^8.11.0",
|
|
||||||
"@types/react": "^19",
|
|
||||||
"@types/react-dom": "^19",
|
|
||||||
"@types/uuid": "^9.0.7",
|
|
||||||
"eslint": "^9",
|
|
||||||
"eslint-config-next": "16.2.4",
|
|
||||||
"tailwindcss": "^4",
|
|
||||||
"tsx": "^4.7.0",
|
|
||||||
"typescript": "^5"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,24 +0,0 @@
|
|||||||
import { defineConfig, devices } from "@playwright/test";
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
testDir: "./tests",
|
|
||||||
fullyParallel: false,
|
|
||||||
timeout: 30_000,
|
|
||||||
expect: { timeout: 10_000 },
|
|
||||||
reporter: [["list"]],
|
|
||||||
use: {
|
|
||||||
baseURL: "http://localhost:3001",
|
|
||||||
trace: "on-first-retry",
|
|
||||||
},
|
|
||||||
projects: [
|
|
||||||
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
|
|
||||||
],
|
|
||||||
webServer: {
|
|
||||||
command: "bun --env-file=.env.local next start -p 3001",
|
|
||||||
url: "http://localhost:3001",
|
|
||||||
reuseExistingServer: !process.env.CI,
|
|
||||||
timeout: 60_000,
|
|
||||||
stdout: "ignore",
|
|
||||||
stderr: "pipe",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@ -1,7 +0,0 @@
|
|||||||
const config = {
|
|
||||||
plugins: {
|
|
||||||
"@tailwindcss/postcss": {},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export default config;
|
|
||||||
@ -1 +0,0 @@
|
|||||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 391 B |
@ -1 +0,0 @@
|
|||||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.0 KiB |
@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.3 KiB |
@ -1 +0,0 @@
|
|||||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 128 B |
@ -1 +0,0 @@
|
|||||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 385 B |
@ -1,81 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
/**
|
|
||||||
* Build Docker image for Admin
|
|
||||||
*
|
|
||||||
* Workflow:
|
|
||||||
* 1. npm ci --legacy-peer-deps (install dependencies)
|
|
||||||
* 2. npm run build (Next.js build, outputs to .next/)
|
|
||||||
* 3. docker build (multi-stage, minimal runtime image)
|
|
||||||
*
|
|
||||||
* Usage:
|
|
||||||
* node scripts/build.js # Build image
|
|
||||||
* node scripts/build.js --no-cache # Build without Docker layer cache
|
|
||||||
*
|
|
||||||
* Environment:
|
|
||||||
* REGISTRY - Docker registry (default: harbor.gitdata.me/gta_team)
|
|
||||||
* TAG - Image tag (default: git short SHA)
|
|
||||||
*/
|
|
||||||
|
|
||||||
const { execSync } = require('child_process');
|
|
||||||
const path = require('path');
|
|
||||||
|
|
||||||
const REGISTRY = process.env.REGISTRY || 'harbor.gitdata.me/gta_team';
|
|
||||||
const GIT_SHA_SHORT = execSync('git rev-parse --short HEAD', { encoding: 'utf8' }).trim();
|
|
||||||
const TAG = process.env.TAG || GIT_SHA_SHORT;
|
|
||||||
const SERVICE = 'admin';
|
|
||||||
const args = process.argv.slice(2);
|
|
||||||
const noCache = args.includes('--no-cache');
|
|
||||||
const rootDir = path.join(__dirname, '..');
|
|
||||||
|
|
||||||
console.log(`\n=== Build Configuration ===`);
|
|
||||||
console.log(`Registry: ${REGISTRY}`);
|
|
||||||
console.log(`Tag: ${TAG}`);
|
|
||||||
console.log(`Service: ${SERVICE}`);
|
|
||||||
console.log(`No Cache: ${noCache}`);
|
|
||||||
console.log('');
|
|
||||||
|
|
||||||
// Step 1: npm ci
|
|
||||||
console.log(`==> Step 1: Installing dependencies (npm ci)`);
|
|
||||||
try {
|
|
||||||
execSync(`npm ci --legacy-peer-deps`, {
|
|
||||||
stdio: 'inherit',
|
|
||||||
cwd: rootDir,
|
|
||||||
});
|
|
||||||
console.log(` [OK] Dependencies installed`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(` [FAIL] npm ci failed`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 2: Next.js build
|
|
||||||
console.log(`\n==> Step 2: Building Next.js (npm run build)`);
|
|
||||||
try {
|
|
||||||
execSync(`npm run build`, {
|
|
||||||
stdio: 'inherit',
|
|
||||||
cwd: rootDir,
|
|
||||||
env: { ...process.env, NEXT_TELEMETRY_DISABLED: '1' },
|
|
||||||
});
|
|
||||||
console.log(` [OK] Next.js build complete`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(` [FAIL] Next.js build failed`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 3: Docker build
|
|
||||||
console.log(`\n==> Step 3: Building Docker image`);
|
|
||||||
const dockerfile = path.join(rootDir, 'Dockerfile');
|
|
||||||
const image = `${REGISTRY}/${SERVICE}:${TAG}`;
|
|
||||||
const buildCmd = `docker build ${noCache ? '--no-cache' : ''} -f "${dockerfile}" -t "${image}" .`;
|
|
||||||
|
|
||||||
console.log(` Building ${image}`);
|
|
||||||
try {
|
|
||||||
execSync(buildCmd, { stdio: 'inherit', cwd: rootDir });
|
|
||||||
console.log(` [OK] ${image}`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(` [FAIL] Docker build failed`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`\n=== Build Complete ===`);
|
|
||||||
console.log(` ${image}`);
|
|
||||||
console.log('');
|
|
||||||
@ -1,110 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
/**
|
|
||||||
* Deploy Admin via Helm
|
|
||||||
*
|
|
||||||
* Usage:
|
|
||||||
* node scripts/deploy.js # Deploy with defaults
|
|
||||||
* node scripts/deploy.js --dry-run # Dry run only
|
|
||||||
*
|
|
||||||
* Environment:
|
|
||||||
* REGISTRY - Docker registry (default: harbor.gitdata.me/gta_team)
|
|
||||||
* TAG - Image tag (default: git short SHA)
|
|
||||||
* NAMESPACE - Kubernetes namespace (default: gitdataai)
|
|
||||||
* RELEASE - Helm release name (default: admin)
|
|
||||||
* KUBECONFIG - Path to kubeconfig (default: ~/.kube/config)
|
|
||||||
*/
|
|
||||||
|
|
||||||
const { execSync } = require('child_process');
|
|
||||||
const path = require('path');
|
|
||||||
const fs = require('fs');
|
|
||||||
|
|
||||||
const REGISTRY = process.env.REGISTRY || 'harbor.gitdata.me/gta_team';
|
|
||||||
const GIT_SHA_SHORT = execSync('git rev-parse --short HEAD', { encoding: 'utf8' }).trim();
|
|
||||||
const TAG = process.env.TAG || process.env.GITHUB_SHA?.substring(0, 8) || GIT_SHA_SHORT;
|
|
||||||
const NAMESPACE = process.env.NAMESPACE || 'gitdataai';
|
|
||||||
const RELEASE = process.env.RELEASE || 'admin';
|
|
||||||
const CHART_PATH = path.join(__dirname, '..', 'deploy');
|
|
||||||
const KUBECONFIG = process.env.KUBECONFIG || path.join(
|
|
||||||
process.env.HOME || process.env.USERPROFILE,
|
|
||||||
'.kube',
|
|
||||||
'config'
|
|
||||||
);
|
|
||||||
|
|
||||||
const args = process.argv.slice(2);
|
|
||||||
const isDryRun = args.includes('--dry-run');
|
|
||||||
|
|
||||||
const SERVICE = 'admin';
|
|
||||||
|
|
||||||
// Validate kubeconfig
|
|
||||||
if (!fs.existsSync(KUBECONFIG)) {
|
|
||||||
console.error(`Error: kubeconfig not found at ${KUBECONFIG}`);
|
|
||||||
console.error('Set KUBECONFIG environment variable or ensure ~/.kube/config exists');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`\n=== Deploy Configuration ===`);
|
|
||||||
console.log(`Registry: ${REGISTRY}`);
|
|
||||||
console.log(`Tag: ${TAG}`);
|
|
||||||
console.log(`Namespace: ${NAMESPACE}`);
|
|
||||||
console.log(`Release: ${RELEASE}`);
|
|
||||||
console.log(`Service: ${SERVICE}`);
|
|
||||||
console.log(`Dry Run: ${isDryRun}`);
|
|
||||||
console.log('');
|
|
||||||
|
|
||||||
// Build helm values override
|
|
||||||
const valuesFile = path.join(CHART_PATH, 'values.yaml');
|
|
||||||
const userValuesFile = path.join(CHART_PATH, 'values.user.yaml');
|
|
||||||
|
|
||||||
const setValues = [
|
|
||||||
`image.registry=${REGISTRY}`,
|
|
||||||
`admin.image.tag='${TAG}'`,
|
|
||||||
];
|
|
||||||
|
|
||||||
const helmArgs = [
|
|
||||||
'upgrade', '--install', RELEASE, CHART_PATH,
|
|
||||||
'--namespace', NAMESPACE,
|
|
||||||
'--create-namespace',
|
|
||||||
'-f', valuesFile,
|
|
||||||
...(fs.existsSync(userValuesFile) ? ['-f', userValuesFile] : []),
|
|
||||||
...setValues.flatMap(v => ['--set', v]),
|
|
||||||
'--wait',
|
|
||||||
'--timeout', '5m',
|
|
||||||
];
|
|
||||||
|
|
||||||
if (isDryRun) {
|
|
||||||
helmArgs.push('--dry-run');
|
|
||||||
console.log('==> Dry run mode - no changes will be made\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helm upgrade
|
|
||||||
console.log(`==> Running helm upgrade`);
|
|
||||||
console.log(` Command: helm ${helmArgs.join(' ')}\n`);
|
|
||||||
|
|
||||||
try {
|
|
||||||
execSync(`helm ${helmArgs.join(' ')}`, {
|
|
||||||
stdio: 'inherit',
|
|
||||||
env: { ...process.env, KUBECONFIG },
|
|
||||||
});
|
|
||||||
console.log(`\n[OK] Deployment ${isDryRun ? '(dry-run) ' : ''}complete`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('\n[FAIL] Deployment failed');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rollout status
|
|
||||||
if (!isDryRun) {
|
|
||||||
console.log('\n==> Checking rollout status');
|
|
||||||
const deploymentName = `${RELEASE}-${SERVICE}`;
|
|
||||||
console.log(` Checking ${deploymentName}...`);
|
|
||||||
try {
|
|
||||||
execSync(
|
|
||||||
`kubectl rollout status deployment/${deploymentName} -n ${NAMESPACE} --timeout=120s`,
|
|
||||||
{ stdio: 'pipe', env: { ...process.env, KUBECONFIG } }
|
|
||||||
);
|
|
||||||
console.log(` [OK] ${deploymentName}`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(` [WARN] ${deploymentName} rollout timeout or failed`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('\n=== Deploy Complete ===\n');
|
|
||||||
@ -1,73 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
/**
|
|
||||||
* Push Admin Docker image to registry
|
|
||||||
*
|
|
||||||
* Usage:
|
|
||||||
* node scripts/push.js # Push image
|
|
||||||
* node scripts/push.js --dry-run # Show what would be pushed
|
|
||||||
*
|
|
||||||
* Environment:
|
|
||||||
* REGISTRY - Docker registry (default: harbor.gitdata.me/gta_team)
|
|
||||||
* TAG - Image tag (default: git short SHA)
|
|
||||||
* DOCKER_USER - Registry username
|
|
||||||
* DOCKER_PASS - Registry password
|
|
||||||
*/
|
|
||||||
|
|
||||||
const { execSync } = require('child_process');
|
|
||||||
const path = require('path');
|
|
||||||
|
|
||||||
const REGISTRY = process.env.REGISTRY || 'harbor.gitdata.me/gta_team';
|
|
||||||
const GIT_SHA_SHORT = execSync('git rev-parse --short HEAD', { encoding: 'utf8' }).trim();
|
|
||||||
const TAG = process.env.TAG || GIT_SHA_SHORT;
|
|
||||||
const SERVICE = 'admin';
|
|
||||||
const DOCKER_USER = process.env.DOCKER_USER || process.env.HARBOR_USERNAME;
|
|
||||||
const DOCKER_PASS = process.env.DOCKER_PASS || process.env.HARBOR_PASSWORD;
|
|
||||||
|
|
||||||
const args = process.argv.slice(2);
|
|
||||||
const isDryRun = args.includes('--dry-run');
|
|
||||||
|
|
||||||
if (!DOCKER_USER || !DOCKER_PASS) {
|
|
||||||
console.error('Error: DOCKER_USER and DOCKER_PASS environment variables are required');
|
|
||||||
console.error('Set HARBOR_USERNAME and HARBOR_PASSWORD as alternative');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const image = `${REGISTRY}/${SERVICE}:${TAG}`;
|
|
||||||
|
|
||||||
console.log(`\n=== Push Configuration ===`);
|
|
||||||
console.log(`Registry: ${REGISTRY}`);
|
|
||||||
console.log(`Tag: ${TAG}`);
|
|
||||||
console.log(`Image: ${image}`);
|
|
||||||
console.log(`Dry Run: ${isDryRun}`);
|
|
||||||
console.log('');
|
|
||||||
|
|
||||||
if (isDryRun) {
|
|
||||||
console.log('[DRY RUN] Would push:', image);
|
|
||||||
console.log('');
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Login
|
|
||||||
console.log(`==> Logging in to ${REGISTRY}`);
|
|
||||||
try {
|
|
||||||
execSync(`docker login ${REGISTRY} -u "${DOCKER_USER}" -p "${DOCKER_PASS}"`, {
|
|
||||||
stdio: 'inherit',
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Login failed');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Push
|
|
||||||
console.log(`\n==> Pushing ${image}`);
|
|
||||||
try {
|
|
||||||
execSync(`docker push "${image}"`, { stdio: 'inherit' });
|
|
||||||
console.log(` [OK] ${image}`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(` [FAIL] Push failed`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`\n=== Push Complete ===`);
|
|
||||||
console.log(` ${image}`);
|
|
||||||
console.log('');
|
|
||||||
@ -1,38 +0,0 @@
|
|||||||
// Custom Next.js server with daily-report cron auto-start
|
|
||||||
const { createServer } = require("http");
|
|
||||||
const { parse } = require("url");
|
|
||||||
const next = require("next");
|
|
||||||
|
|
||||||
const dev = process.env.NODE_ENV !== "production";
|
|
||||||
const hostname = "0.0.0.0";
|
|
||||||
const port = parseInt(process.env.PORT || "3000", 10);
|
|
||||||
|
|
||||||
const app = next({ dev, hostname, port });
|
|
||||||
const handle = app.getRequestHandler();
|
|
||||||
|
|
||||||
app.prepare().then(async () => {
|
|
||||||
// Start daily report cron before accepting requests
|
|
||||||
try {
|
|
||||||
const { startDailyReportCron } = require("./src/lib/daily-report-cron");
|
|
||||||
startDailyReportCron();
|
|
||||||
console.log("[server] Daily report cron started");
|
|
||||||
} catch (e) {
|
|
||||||
console.warn("[server] Failed to start daily report cron:", e.message);
|
|
||||||
}
|
|
||||||
|
|
||||||
createServer(async (req, res) => {
|
|
||||||
try {
|
|
||||||
const parsedUrl = parse(req.url, true);
|
|
||||||
await handle(req, res, parsedUrl);
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Error occurred handling", req.url, err);
|
|
||||||
res.statusCode = 500;
|
|
||||||
res.end("internal server error");
|
|
||||||
}
|
|
||||||
}).once("error", (err) => {
|
|
||||||
console.error(err);
|
|
||||||
process.exit(1);
|
|
||||||
}).listen(port, () => {
|
|
||||||
console.log(`> Ready on http://${hostname}:${port}`);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,535 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
|
|
||||||
interface Provider {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
display_name: string;
|
|
||||||
website: string | null;
|
|
||||||
status: string;
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
}
|
|
||||||
interface Model {
|
|
||||||
id: string;
|
|
||||||
provider_id: string;
|
|
||||||
name: string;
|
|
||||||
modality: string;
|
|
||||||
capability: string;
|
|
||||||
context_length: number;
|
|
||||||
max_output_tokens: number | null;
|
|
||||||
training_cutoff: string | null;
|
|
||||||
is_open_source: boolean;
|
|
||||||
status: string;
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
}
|
|
||||||
interface Version {
|
|
||||||
id: string;
|
|
||||||
model_id: string;
|
|
||||||
version: string;
|
|
||||||
release_date: string | null;
|
|
||||||
change_log: string | null;
|
|
||||||
is_default: boolean;
|
|
||||||
status: string;
|
|
||||||
created_at: string;
|
|
||||||
}
|
|
||||||
interface Pricing {
|
|
||||||
id: number;
|
|
||||||
model_version_id: string;
|
|
||||||
model_name: string;
|
|
||||||
input_price_per_1k_tokens: string;
|
|
||||||
output_price_per_1k_tokens: string;
|
|
||||||
currency: string;
|
|
||||||
effective_from: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
type Tab = "providers" | "models" | "versions" | "pricing";
|
|
||||||
|
|
||||||
interface EditProvider {
|
|
||||||
name: string;
|
|
||||||
display_name: string;
|
|
||||||
website: string;
|
|
||||||
}
|
|
||||||
interface EditModel {
|
|
||||||
provider_id: string;
|
|
||||||
name: string;
|
|
||||||
modality: string;
|
|
||||||
capability: string;
|
|
||||||
context_length: string;
|
|
||||||
max_output_tokens: string;
|
|
||||||
training_cutoff: string;
|
|
||||||
}
|
|
||||||
interface EditVersion {
|
|
||||||
model_id: string;
|
|
||||||
version: string;
|
|
||||||
release_date: string;
|
|
||||||
change_log: string;
|
|
||||||
}
|
|
||||||
interface EditPricing {
|
|
||||||
input_price_per_1k_tokens: string;
|
|
||||||
output_price_per_1k_tokens: string;
|
|
||||||
currency: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const MODALITY_OPTIONS = ["text", "chat", "code", "embedding", "image", "audio", "video"];
|
|
||||||
const CAPABILITY_OPTIONS = ["chat", "completion", "embedding", "image-generation", "code-generation"];
|
|
||||||
|
|
||||||
export default function AiPage() {
|
|
||||||
const [providers, setProviders] = useState<Provider[]>([]);
|
|
||||||
const [models, setModels] = useState<Model[]>([]);
|
|
||||||
const [versions, setVersions] = useState<Version[]>([]);
|
|
||||||
const [pricing, setPricing] = useState<Pricing[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [tab, setTab] = useState<Tab>("providers");
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
const [msg, setMsg] = useState<{ type: "success" | "error"; text: string } | null>(null);
|
|
||||||
|
|
||||||
// Modals
|
|
||||||
const [showProviderModal, setShowProviderModal] = useState(false);
|
|
||||||
const [editProvider, setEditProvider] = useState<EditProvider>({ name: "", display_name: "", website: "" });
|
|
||||||
const [editingProviderId, setEditingProviderId] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const [showModelModal, setShowModelModal] = useState(false);
|
|
||||||
const [editModel, setEditModel] = useState<EditModel>({ provider_id: "", name: "", modality: "chat", capability: "chat", context_length: "8192", max_output_tokens: "", training_cutoff: "" });
|
|
||||||
const [editingModelId, setEditingModelId] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const [showVersionModal, setShowVersionModal] = useState(false);
|
|
||||||
const [editVersion, setEditVersion] = useState<EditVersion>({ model_id: "", version: "", release_date: "", change_log: "" });
|
|
||||||
const [editingVersionId, setEditingVersionId] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const [editingPricingId, setEditingPricingId] = useState<number | null>(null);
|
|
||||||
const [editPricing, setEditPricing] = useState<EditPricing>({ input_price_per_1k_tokens: "", output_price_per_1k_tokens: "", currency: "USD" });
|
|
||||||
|
|
||||||
function showMsg(type: "success" | "error", text: string) {
|
|
||||||
setMsg({ type, text });
|
|
||||||
setTimeout(() => setMsg(null), 4000);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadData() {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const res = await fetch("/api/platform/ai");
|
|
||||||
const data = await res.json();
|
|
||||||
setProviders(data.providers || []);
|
|
||||||
setModels(data.models || []);
|
|
||||||
// versions and pricing need to be fetched separately — load from model data
|
|
||||||
setPricing(data.pricing || []);
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => { loadData(); }, []);
|
|
||||||
|
|
||||||
// Provider CRUD
|
|
||||||
function openNewProvider() {
|
|
||||||
setEditingProviderId(null);
|
|
||||||
setEditProvider({ name: "", display_name: "", website: "" });
|
|
||||||
setShowProviderModal(true);
|
|
||||||
}
|
|
||||||
function openEditProvider(p: Provider) {
|
|
||||||
setEditingProviderId(p.id);
|
|
||||||
setEditProvider({ name: p.name, display_name: p.display_name, website: p.website || "" });
|
|
||||||
setShowProviderModal(true);
|
|
||||||
}
|
|
||||||
async function saveProvider() {
|
|
||||||
setSaving(true);
|
|
||||||
try {
|
|
||||||
const body = { ...editProvider, website: editProvider.website || null };
|
|
||||||
const res = editingProviderId
|
|
||||||
? await fetch(`/api/admin/ai/providers?id=${editingProviderId}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) })
|
|
||||||
: await fetch("/api/admin/ai/providers", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
|
|
||||||
const data = await res.json();
|
|
||||||
if (!res.ok) { showMsg("error", data.error || "保存失败"); return; }
|
|
||||||
showMsg("success", editingProviderId ? "Provider 已更新" : "Provider 已创建");
|
|
||||||
setShowProviderModal(false);
|
|
||||||
loadData();
|
|
||||||
} catch { showMsg("error", "保存失败"); }
|
|
||||||
finally { setSaving(false); }
|
|
||||||
}
|
|
||||||
async function deleteProvider(id: string, name: string) {
|
|
||||||
if (!confirm(`删除 Provider "${name}"?`)) return;
|
|
||||||
const res = await fetch(`/api/admin/ai/providers?id=${id}`, { method: "DELETE" });
|
|
||||||
const data = await res.json();
|
|
||||||
if (!res.ok) { showMsg("error", data.error || "删除失败"); return; }
|
|
||||||
showMsg("success", "已删除");
|
|
||||||
loadData();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Model CRUD
|
|
||||||
function openNewModel() {
|
|
||||||
setEditingModelId(null);
|
|
||||||
setEditModel({ provider_id: providers[0]?.id || "", name: "", modality: "chat", capability: "chat", context_length: "8192", max_output_tokens: "", training_cutoff: "" });
|
|
||||||
setShowModelModal(true);
|
|
||||||
}
|
|
||||||
function openEditModel(m: Model) {
|
|
||||||
setEditingModelId(m.id);
|
|
||||||
setEditModel({ provider_id: m.provider_id, name: m.name, modality: m.modality, capability: m.capability, context_length: String(m.context_length), max_output_tokens: m.max_output_tokens ? String(m.max_output_tokens) : "", training_cutoff: m.training_cutoff ? m.training_cutoff.split("T")[0] : "" });
|
|
||||||
setShowModelModal(true);
|
|
||||||
}
|
|
||||||
async function saveModel() {
|
|
||||||
setSaving(true);
|
|
||||||
try {
|
|
||||||
const body = {
|
|
||||||
provider_id: editModel.provider_id,
|
|
||||||
name: editModel.name,
|
|
||||||
modality: editModel.modality,
|
|
||||||
capability: editModel.capability,
|
|
||||||
context_length: parseInt(editModel.context_length) || 8192,
|
|
||||||
max_output_tokens: editModel.max_output_tokens ? parseInt(editModel.max_output_tokens) : null,
|
|
||||||
training_cutoff: editModel.training_cutoff ? new Date(editModel.training_cutoff).toISOString() : null,
|
|
||||||
};
|
|
||||||
const res = editingModelId
|
|
||||||
? await fetch(`/api/admin/ai/models?id=${editingModelId}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) })
|
|
||||||
: await fetch("/api/admin/ai/models", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
|
|
||||||
const data = await res.json();
|
|
||||||
if (!res.ok) { showMsg("error", data.error || "保存失败"); return; }
|
|
||||||
showMsg("success", editingModelId ? "模型已更新" : "模型已创建");
|
|
||||||
setShowModelModal(false);
|
|
||||||
loadData();
|
|
||||||
} catch { showMsg("error", "保存失败"); }
|
|
||||||
finally { setSaving(false); }
|
|
||||||
}
|
|
||||||
async function deleteModel(id: string, name: string) {
|
|
||||||
if (!confirm(`删除模型 "${name}"?`)) return;
|
|
||||||
const res = await fetch(`/api/admin/ai/models?id=${id}`, { method: "DELETE" });
|
|
||||||
const data = await res.json();
|
|
||||||
if (!res.ok) { showMsg("error", data.error || "删除失败"); return; }
|
|
||||||
showMsg("success", "已删除");
|
|
||||||
loadData();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Version CRUD
|
|
||||||
function openNewVersion() {
|
|
||||||
setEditingVersionId(null);
|
|
||||||
setEditVersion({ model_id: models[0]?.id || "", version: "", release_date: "", change_log: "" });
|
|
||||||
setShowVersionModal(true);
|
|
||||||
}
|
|
||||||
function openEditVersion(v: Version) {
|
|
||||||
setEditingVersionId(v.id);
|
|
||||||
setEditVersion({ model_id: v.model_id, version: v.version, release_date: v.release_date ? v.release_date.split("T")[0] : "", change_log: v.change_log || "" });
|
|
||||||
setShowVersionModal(true);
|
|
||||||
}
|
|
||||||
async function saveVersion() {
|
|
||||||
setSaving(true);
|
|
||||||
try {
|
|
||||||
const body = {
|
|
||||||
model_id: editVersion.model_id,
|
|
||||||
version: editVersion.version,
|
|
||||||
release_date: editVersion.release_date ? new Date(editVersion.release_date).toISOString() : null,
|
|
||||||
change_log: editVersion.change_log || null,
|
|
||||||
};
|
|
||||||
const res = editingVersionId
|
|
||||||
? await fetch(`/api/admin/ai/versions?id=${editingVersionId}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) })
|
|
||||||
: await fetch("/api/admin/ai/versions", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
|
|
||||||
const data = await res.json();
|
|
||||||
if (!res.ok) { showMsg("error", data.error || "保存失败"); return; }
|
|
||||||
showMsg("success", editingVersionId ? "版本已更新" : "版本已创建");
|
|
||||||
setShowVersionModal(false);
|
|
||||||
loadData();
|
|
||||||
} catch { showMsg("error", "保存失败"); }
|
|
||||||
finally { setSaving(false); }
|
|
||||||
}
|
|
||||||
async function deleteVersion(id: string, version: string) {
|
|
||||||
if (!confirm(`删除版本 "${version}"?`)) return;
|
|
||||||
const res = await fetch(`/api/admin/ai/versions?id=${id}`, { method: "DELETE" });
|
|
||||||
const data = await res.json();
|
|
||||||
if (!res.ok) { showMsg("error", data.error || "删除失败"); return; }
|
|
||||||
showMsg("success", "已删除");
|
|
||||||
loadData();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pricing inline edit
|
|
||||||
function startEditPricing(p: Pricing) {
|
|
||||||
setEditingPricingId(p.id);
|
|
||||||
setEditPricing({ input_price_per_1k_tokens: p.input_price_per_1k_tokens, output_price_per_1k_tokens: p.output_price_per_1k_tokens, currency: p.currency });
|
|
||||||
}
|
|
||||||
async function savePricing(id: number) {
|
|
||||||
setSaving(true);
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/admin/ai/pricing/${id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(editPricing) });
|
|
||||||
const data = await res.json();
|
|
||||||
if (!res.ok) { showMsg("error", data.error || "保存失败"); return; }
|
|
||||||
showMsg("success", "定价已更新");
|
|
||||||
setEditingPricingId(null);
|
|
||||||
loadData();
|
|
||||||
} catch { showMsg("error", "保存失败"); }
|
|
||||||
finally { setSaving(false); }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (loading) return <div className="admin-content"><div className="loading">加载中...</div></div>;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="admin-content">
|
|
||||||
<div className="page-header">
|
|
||||||
<h1 className="page-title">AI 模型管理</h1>
|
|
||||||
<p className="page-subtitle">Provider / Model / 版本 / 定价</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="toolbar">
|
|
||||||
<button className={`btn btn-sm ${tab === "providers" ? "btn-primary" : "btn-secondary"}`} onClick={() => setTab("providers")}>Provider ({providers.length})</button>
|
|
||||||
<button className={`btn btn-sm ${tab === "models" ? "btn-primary" : "btn-secondary"}`} onClick={() => setTab("models")}>模型 ({models.length})</button>
|
|
||||||
<button className={`btn btn-sm ${tab === "versions" ? "btn-primary" : "btn-secondary"}`} onClick={() => setTab("versions")}>版本</button>
|
|
||||||
<button className={`btn btn-sm ${tab === "pricing" ? "btn-primary" : "btn-secondary"}`} onClick={() => setTab("pricing")}>定价</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{msg && (
|
|
||||||
<div className={`alert ${msg.type === "error" ? "alert-error" : "alert-success"}`} style={{ marginBottom: "12px" }}>
|
|
||||||
{msg.text}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="card">
|
|
||||||
{tab === "providers" && (
|
|
||||||
<>
|
|
||||||
<div style={{ padding: "12px", borderBottom: "1px solid #e5e5e5" }}>
|
|
||||||
<button className="btn btn-sm btn-primary" onClick={openNewProvider}>+ 新建 Provider</button>
|
|
||||||
</div>
|
|
||||||
<div className="table-container">
|
|
||||||
<table className="data-table">
|
|
||||||
<thead><tr><th>ID</th><th>名称</th><th>显示名</th><th>官网</th><th>状态</th><th>操作</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
{providers.map((p) => (
|
|
||||||
<tr key={p.id}>
|
|
||||||
<td><code style={{ fontSize: "11px" }}>{p.id.slice(0, 8)}</code></td>
|
|
||||||
<td>{p.name}</td>
|
|
||||||
<td>{p.display_name}</td>
|
|
||||||
<td><a href={p.website || "#"} target="_blank" rel="noopener" style={{ fontSize: "12px" }}>{p.website || "-"}</a></td>
|
|
||||||
<td><span className={`badge ${p.status === "active" ? "badge-success" : "badge-neutral"}`}>{p.status}</span></td>
|
|
||||||
<td>
|
|
||||||
<button className="btn btn-sm btn-secondary" style={{ marginRight: "4px" }} onClick={() => openEditProvider(p)}>编辑</button>
|
|
||||||
<button className="btn btn-sm" style={{ color: "#dc2626", border: "none", background: "none", padding: "2px 6px", cursor: "pointer" }} onClick={() => deleteProvider(p.id, p.name)}>删除</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
{providers.length === 0 && <tr><td colSpan={6} style={{ textAlign: "center", padding: "24px", color: "#737373" }}>暂无 Provider</td></tr>}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{tab === "models" && (
|
|
||||||
<>
|
|
||||||
<div style={{ padding: "12px", borderBottom: "1px solid #e5e5e5" }}>
|
|
||||||
<button className="btn btn-sm btn-primary" disabled={!providers.length} onClick={openNewModel}>+ 新建模型</button>
|
|
||||||
</div>
|
|
||||||
<div className="table-container">
|
|
||||||
<table className="data-table">
|
|
||||||
<thead><tr><th>Provider</th><th>名称</th><th>Modality</th><th>上下文长度</th><th>状态</th><th>操作</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
{models.map((m) => {
|
|
||||||
const prov = providers.find(p => p.id === m.provider_id);
|
|
||||||
return (
|
|
||||||
<tr key={m.id}>
|
|
||||||
<td>{prov?.display_name || m.provider_id.slice(0, 8)}</td>
|
|
||||||
<td>{m.name}</td>
|
|
||||||
<td><span className="badge badge-neutral">{m.modality}</span></td>
|
|
||||||
<td>{m.context_length ? `${(m.context_length / 1000).toFixed(0)}K` : "-"}</td>
|
|
||||||
<td><span className={`badge ${m.status === "active" ? "badge-success" : "badge-neutral"}`}>{m.status}</span></td>
|
|
||||||
<td>
|
|
||||||
<button className="btn btn-sm btn-secondary" style={{ marginRight: "4px" }} onClick={() => openEditModel(m)}>编辑</button>
|
|
||||||
<button className="btn btn-sm" style={{ color: "#dc2626", border: "none", background: "none", padding: "2px 6px", cursor: "pointer" }} onClick={() => deleteModel(m.id, m.name)}>删除</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{models.length === 0 && <tr><td colSpan={6} style={{ textAlign: "center", padding: "24px", color: "#737373" }}>暂无模型</td></tr>}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{tab === "versions" && (
|
|
||||||
<>
|
|
||||||
<div style={{ padding: "12px", borderBottom: "1px solid #e5e5e5" }}>
|
|
||||||
<button className="btn btn-sm btn-primary" disabled={!models.length} onClick={openNewVersion}>+ 新建版本</button>
|
|
||||||
</div>
|
|
||||||
<div className="table-container">
|
|
||||||
<table className="data-table">
|
|
||||||
<thead><tr><th>Model</th><th>版本</th><th>发布日期</th><th>变更日志</th><th>状态</th><th>操作</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
{versions.map((v) => {
|
|
||||||
const mdl = models.find(m => m.id === v.model_id);
|
|
||||||
return (
|
|
||||||
<tr key={v.id}>
|
|
||||||
<td>{mdl?.name || v.model_id.slice(0, 8)}</td>
|
|
||||||
<td><code>{v.version}</code></td>
|
|
||||||
<td>{v.release_date ? new Date(v.release_date).toLocaleDateString() : "-"}</td>
|
|
||||||
<td style={{ fontSize: "12px", maxWidth: "200px", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{v.change_log || "-"}</td>
|
|
||||||
<td><span className={`badge ${v.status === "active" ? "badge-success" : "badge-neutral"}`}>{v.status}</span></td>
|
|
||||||
<td>
|
|
||||||
<button className="btn btn-sm btn-secondary" style={{ marginRight: "4px" }} onClick={() => openEditVersion(v)}>编辑</button>
|
|
||||||
<button className="btn btn-sm" style={{ color: "#dc2626", border: "none", background: "none", padding: "2px 6px", cursor: "pointer" }} onClick={() => deleteVersion(v.id, v.version)}>删除</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{versions.length === 0 && <tr><td colSpan={6} style={{ textAlign: "center", padding: "24px", color: "#737373" }}>暂无版本,点击上方「新建版本」添加</td></tr>}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{tab === "pricing" && (
|
|
||||||
<div className="table-container">
|
|
||||||
<table className="data-table">
|
|
||||||
<thead><tr><th>模型</th><th>输入 $/1K</th><th>输出 $/1K</th><th>货币</th><th>生效时间</th><th>操作</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
{pricing.map((p) => (
|
|
||||||
<tr key={p.id}>
|
|
||||||
<td style={{ fontSize: "12px", maxWidth: "200px", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }} title={p.model_name}>{p.model_name}</td>
|
|
||||||
<td>
|
|
||||||
{editingPricingId === p.id ? (
|
|
||||||
<input className="form-input" style={{ width: "120px" }} value={editPricing.input_price_per_1k_tokens} onChange={e => setEditPricing(p => ({ ...p, input_price_per_1k_tokens: e.target.value }))} />
|
|
||||||
) : (
|
|
||||||
<span>${parseFloat(p.input_price_per_1k_tokens).toFixed(6)}</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{editingPricingId === p.id ? (
|
|
||||||
<input className="form-input" style={{ width: "120px" }} value={editPricing.output_price_per_1k_tokens} onChange={e => setEditPricing(p => ({ ...p, output_price_per_1k_tokens: e.target.value }))} />
|
|
||||||
) : (
|
|
||||||
<span>${parseFloat(p.output_price_per_1k_tokens).toFixed(6)}</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{editingPricingId === p.id ? (
|
|
||||||
<input className="form-input" style={{ width: "80px" }} value={editPricing.currency} onChange={e => setEditPricing(p => ({ ...p, currency: e.target.value }))} />
|
|
||||||
) : (
|
|
||||||
<span>{p.currency}</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td>{new Date(p.effective_from).toLocaleDateString()}</td>
|
|
||||||
<td>
|
|
||||||
{editingPricingId === p.id ? (
|
|
||||||
<>
|
|
||||||
<button className="btn btn-sm btn-primary" disabled={saving} style={{ marginRight: "4px" }} onClick={() => savePricing(p.id)}>{saving ? "..." : "保存"}</button>
|
|
||||||
<button className="btn btn-sm btn-secondary" onClick={() => setEditingPricingId(null)}>取消</button>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<button className="btn btn-sm btn-secondary" onClick={() => startEditPricing(p)}>编辑</button>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
{pricing.length === 0 && <tr><td colSpan={6} style={{ textAlign: "center", padding: "24px", color: "#737373" }}>暂无定价记录</td></tr>}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Provider Modal */}
|
|
||||||
{showProviderModal && (
|
|
||||||
<div className="modal-overlay" onClick={() => setShowProviderModal(false)}>
|
|
||||||
<div className="modal" onClick={e => e.stopPropagation()} style={{ maxWidth: "460px" }}>
|
|
||||||
<h2 className="modal-title">{editingProviderId ? "编辑 Provider" : "新建 Provider"}</h2>
|
|
||||||
<div className="form-group">
|
|
||||||
<label className="form-label">名称 (name)</label>
|
|
||||||
<input className="form-input" value={editProvider.name} onChange={e => setEditProvider(p => ({ ...p, name: e.target.value }))} placeholder="例如:openrouter" autoFocus />
|
|
||||||
</div>
|
|
||||||
<div className="form-group">
|
|
||||||
<label className="form-label">显示名 (display_name)</label>
|
|
||||||
<input className="form-input" value={editProvider.display_name} onChange={e => setEditProvider(p => ({ ...p, display_name: e.target.value }))} placeholder="例如:OpenRouter" />
|
|
||||||
</div>
|
|
||||||
<div className="form-group">
|
|
||||||
<label className="form-label">官网 (website,可选)</label>
|
|
||||||
<input className="form-input" value={editProvider.website} onChange={e => setEditProvider(p => ({ ...p, website: e.target.value }))} placeholder="https://openrouter.ai" />
|
|
||||||
</div>
|
|
||||||
<div className="modal-footer">
|
|
||||||
<button className="btn btn-secondary" onClick={() => setShowProviderModal(false)}>取消</button>
|
|
||||||
<button className="btn btn-primary" disabled={saving || !editProvider.name || !editProvider.display_name} onClick={saveProvider}>{saving ? "保存中..." : "保存"}</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Model Modal */}
|
|
||||||
{showModelModal && (
|
|
||||||
<div className="modal-overlay" onClick={() => setShowModelModal(false)}>
|
|
||||||
<div className="modal" onClick={e => e.stopPropagation()} style={{ maxWidth: "520px" }}>
|
|
||||||
<h2 className="modal-title">{editingModelId ? "编辑模型" : "新建模型"}</h2>
|
|
||||||
<div className="form-group">
|
|
||||||
<label className="form-label">Provider</label>
|
|
||||||
<select className="form-input" value={editModel.provider_id} onChange={e => setEditModel(m => ({ ...m, provider_id: e.target.value }))}>
|
|
||||||
<option value="">选择 Provider...</option>
|
|
||||||
{providers.map(p => <option key={p.id} value={p.id}>{p.display_name}</option>)}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className="form-group">
|
|
||||||
<label className="form-label">模型名称</label>
|
|
||||||
<input className="form-input" value={editModel.name} onChange={e => setEditModel(m => ({ ...m, name: e.target.value }))} placeholder="例如:anthropic/claude-3.5-sonnet" />
|
|
||||||
</div>
|
|
||||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "12px" }}>
|
|
||||||
<div className="form-group">
|
|
||||||
<label className="form-label">Modality</label>
|
|
||||||
<select className="form-input" value={editModel.modality} onChange={e => setEditModel(m => ({ ...m, modality: e.target.value }))}>
|
|
||||||
{MODALITY_OPTIONS.map(o => <option key={o} value={o}>{o}</option>)}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className="form-group">
|
|
||||||
<label className="form-label">Capability</label>
|
|
||||||
<select className="form-input" value={editModel.capability} onChange={e => setEditModel(m => ({ ...m, capability: e.target.value }))}>
|
|
||||||
{CAPABILITY_OPTIONS.map(o => <option key={o} value={o}>{o}</option>)}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "12px" }}>
|
|
||||||
<div className="form-group">
|
|
||||||
<label className="form-label">上下文长度</label>
|
|
||||||
<input className="form-input" type="number" value={editModel.context_length} onChange={e => setEditModel(m => ({ ...m, context_length: e.target.value }))} placeholder="8192" />
|
|
||||||
</div>
|
|
||||||
<div className="form-group">
|
|
||||||
<label className="form-label">最大输出 tokens</label>
|
|
||||||
<input className="form-input" type="number" value={editModel.max_output_tokens} onChange={e => setEditModel(m => ({ ...m, max_output_tokens: e.target.value }))} placeholder="可选" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="modal-footer">
|
|
||||||
<button className="btn btn-secondary" onClick={() => setShowModelModal(false)}>取消</button>
|
|
||||||
<button className="btn btn-primary" disabled={saving || !editModel.provider_id || !editModel.name} onClick={saveModel}>{saving ? "保存中..." : "保存"}</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Version Modal */}
|
|
||||||
{showVersionModal && (
|
|
||||||
<div className="modal-overlay" onClick={() => setShowVersionModal(false)}>
|
|
||||||
<div className="modal" onClick={e => e.stopPropagation()} style={{ maxWidth: "460px" }}>
|
|
||||||
<h2 className="modal-title">{editingVersionId ? "编辑版本" : "新建版本"}</h2>
|
|
||||||
<div className="form-group">
|
|
||||||
<label className="form-label">所属模型</label>
|
|
||||||
<select className="form-input" value={editVersion.model_id} onChange={e => setEditVersion(v => ({ ...v, model_id: e.target.value }))}>
|
|
||||||
<option value="">选择模型...</option>
|
|
||||||
{models.map(m => <option key={m.id} value={m.id}>{m.name}</option>)}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className="form-group">
|
|
||||||
<label className="form-label">版本号</label>
|
|
||||||
<input className="form-input" value={editVersion.version} onChange={e => setEditVersion(v => ({ ...v, version: e.target.value }))} placeholder="例如:1.0.0" autoFocus />
|
|
||||||
</div>
|
|
||||||
<div className="form-group">
|
|
||||||
<label className="form-label">发布日期</label>
|
|
||||||
<input className="form-input" type="date" value={editVersion.release_date} onChange={e => setEditVersion(v => ({ ...v, release_date: e.target.value }))} />
|
|
||||||
</div>
|
|
||||||
<div className="form-group">
|
|
||||||
<label className="form-label">变更日志</label>
|
|
||||||
<textarea className="form-input" rows={3} value={editVersion.change_log} onChange={e => setEditVersion(v => ({ ...v, change_log: e.target.value }))} placeholder="可选" />
|
|
||||||
</div>
|
|
||||||
<div className="modal-footer">
|
|
||||||
<button className="btn btn-secondary" onClick={() => setShowVersionModal(false)}>取消</button>
|
|
||||||
<button className="btn btn-primary" disabled={saving || !editVersion.model_id || !editVersion.version} onClick={saveVersion}>{saving ? "保存中..." : "保存"}</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,233 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { format } from "date-fns";
|
|
||||||
|
|
||||||
interface ApiToken {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
token_prefix: string;
|
|
||||||
permissions: string[];
|
|
||||||
created_at: string;
|
|
||||||
last_used_at: string | null;
|
|
||||||
expires_at: string | null;
|
|
||||||
is_active: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ALL_PERMISSIONS = [
|
|
||||||
{ value: "user:read", label: "用户读取" },
|
|
||||||
{ value: "user:create", label: "用户创建" },
|
|
||||||
{ value: "user:update", label: "用户更新" },
|
|
||||||
{ value: "user:delete", label: "用户删除" },
|
|
||||||
{ value: "role:read", label: "角色读取" },
|
|
||||||
{ value: "role:create", label: "角色创建" },
|
|
||||||
{ value: "role:update", label: "角色更新" },
|
|
||||||
{ value: "role:delete", label: "角色删除" },
|
|
||||||
{ value: "log:read", label: "日志读取" },
|
|
||||||
{ value: "session:manage", label: "会话管理" },
|
|
||||||
{ value: "platform:read", label: "平台读取" },
|
|
||||||
{ value: "platform:manage", label: "平台管理" },
|
|
||||||
{ value: "*", label: "全部权限(超级)" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function ApiTokensPage() {
|
|
||||||
const [tokens, setTokens] = useState<ApiToken[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [showCreate, setShowCreate] = useState(false);
|
|
||||||
const [newName, setNewName] = useState("");
|
|
||||||
const [newPerms, setNewPerms] = useState<string[]>([]);
|
|
||||||
const [newExpiry, setNewExpiry] = useState("");
|
|
||||||
const [creating, setCreating] = useState(false);
|
|
||||||
const [createdToken, setCreatedToken] = useState("");
|
|
||||||
const [createError, setCreateError] = useState("");
|
|
||||||
|
|
||||||
useEffect(() => { loadTokens(); }, []);
|
|
||||||
|
|
||||||
function loadTokens() {
|
|
||||||
setLoading(true);
|
|
||||||
fetch("/api/api-tokens")
|
|
||||||
.then((r) => r.json())
|
|
||||||
.then((d) => setTokens(d.tokens || []))
|
|
||||||
.catch(console.error)
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleCreate() {
|
|
||||||
if (!newName.trim()) { setCreateError("名称不能为空"); return; }
|
|
||||||
setCreating(true);
|
|
||||||
setCreateError("");
|
|
||||||
try {
|
|
||||||
const body: Record<string, unknown> = { name: newName, permissions: newPerms };
|
|
||||||
if (newExpiry) body.expiresInDays = parseInt(newExpiry, 10);
|
|
||||||
const res = await fetch("/api/api-tokens", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
if (!res.ok) { setCreateError(data.error || "创建失败"); return; }
|
|
||||||
setCreatedToken(data.token);
|
|
||||||
loadTokens();
|
|
||||||
setShowCreate(false);
|
|
||||||
setNewName("");
|
|
||||||
setNewPerms([]);
|
|
||||||
setNewExpiry("");
|
|
||||||
} catch { setCreateError("创建失败"); }
|
|
||||||
finally { setCreating(false); }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleDelete(t: ApiToken) {
|
|
||||||
if (!confirm(`确定删除 Token「${t.name}」吗?`)) return;
|
|
||||||
await fetch(`/api/api-tokens/${t.id}`, { method: "DELETE" });
|
|
||||||
loadTokens();
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="admin-content">
|
|
||||||
<div className="page-header" style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start" }}>
|
|
||||||
<div>
|
|
||||||
<h1 className="page-title">API Token</h1>
|
|
||||||
<p className="page-subtitle">用于自动化工具的 API 访问凭证(Bearer Token 认证)</p>
|
|
||||||
</div>
|
|
||||||
<button className="btn btn-primary" onClick={() => setShowCreate(true)}>+ 新建 Token</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Token 使用说明 */}
|
|
||||||
<div className="card" style={{ marginBottom: "16px", background: "#fafafa" }}>
|
|
||||||
<div style={{ fontSize: "13px", color: "#52525b" }}>
|
|
||||||
<strong>使用方法:</strong>在请求头中添加 <code>Authorization: Bearer <your-token></code>。
|
|
||||||
API Token 可访问除 Token 管理本身之外的所有 API。
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="card">
|
|
||||||
{loading ? <div className="loading">加载中...</div> : tokens.length === 0 ? (
|
|
||||||
<div className="empty-state">
|
|
||||||
<div className="empty-state-icon">🔑</div>
|
|
||||||
<p>暂无 API Token,点击上方按钮创建</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="table-container">
|
|
||||||
<table className="data-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>名称</th>
|
|
||||||
<th>前缀</th>
|
|
||||||
<th>权限</th>
|
|
||||||
<th>有效期</th>
|
|
||||||
<th>最后使用</th>
|
|
||||||
<th>操作</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{tokens.map((t) => (
|
|
||||||
<tr key={t.id}>
|
|
||||||
<td style={{ fontWeight: 500 }}>{t.name}</td>
|
|
||||||
<td><code style={{ fontSize: "11px" }}>{t.token_prefix}***</code></td>
|
|
||||||
<td>
|
|
||||||
<div style={{ display: "flex", flexWrap: "wrap", gap: "4px" }}>
|
|
||||||
{t.permissions.includes("*") ? (
|
|
||||||
<span className="badge badge-success">全部权限</span>
|
|
||||||
) : t.permissions.length === 0 ? (
|
|
||||||
<span style={{ color: "#737373", fontSize: "12px" }}>无</span>
|
|
||||||
) : (
|
|
||||||
t.permissions.map((p) => (
|
|
||||||
<span key={p} className="badge badge-neutral" style={{ fontSize: "11px" }}>{p}</span>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td style={{ fontSize: "13px" }}>
|
|
||||||
{t.expires_at
|
|
||||||
? new Date(t.expires_at) < new Date()
|
|
||||||
? <span style={{ color: "#dc2626" }}>已过期</span>
|
|
||||||
: format(new Date(t.expires_at), "yyyy-MM-dd")
|
|
||||||
: "永不过期"}
|
|
||||||
</td>
|
|
||||||
<td style={{ fontSize: "13px" }}>
|
|
||||||
{t.last_used_at
|
|
||||||
? format(new Date(t.last_used_at), "yyyy-MM-dd HH:mm")
|
|
||||||
: <span style={{ color: "#a3a3a3" }}>未使用</span>}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<button className="btn btn-danger btn-sm" onClick={() => handleDelete(t)}>删除</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 创建 Token 弹窗 */}
|
|
||||||
{showCreate && (
|
|
||||||
<div className="modal-overlay" onClick={() => { setShowCreate(false); setCreatedToken(""); }}>
|
|
||||||
<div className="modal" onClick={(e) => e.stopPropagation()} style={{ maxWidth: "520px" }}>
|
|
||||||
<h2 className="modal-title">新建 API Token</h2>
|
|
||||||
|
|
||||||
{createdToken ? (
|
|
||||||
<>
|
|
||||||
<div className="alert alert-success" style={{ marginBottom: "16px" }}>
|
|
||||||
Token 创建成功!请立即复制保存,<strong>关闭后将无法再次查看</strong>。
|
|
||||||
</div>
|
|
||||||
<div className="form-group">
|
|
||||||
<label className="form-label">您的 API Token</label>
|
|
||||||
<div style={{
|
|
||||||
background: "#f4f4f5", border: "1px solid #e5e5e5",
|
|
||||||
borderRadius: "6px", padding: "12px",
|
|
||||||
fontFamily: "monospace", fontSize: "13px",
|
|
||||||
wordBreak: "break-all", color: "#171717"
|
|
||||||
}}>
|
|
||||||
{createdToken}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="modal-footer">
|
|
||||||
<button className="btn btn-primary" onClick={() => { setCreatedToken(""); setShowCreate(false); }}>关闭</button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{createError && <div className="alert alert-error">{createError}</div>}
|
|
||||||
<div className="form-group">
|
|
||||||
<label className="form-label">Token 名称 *</label>
|
|
||||||
<input type="text" className="form-input" value={newName}
|
|
||||||
onChange={(e) => setNewName(e.target.value)} placeholder="例如:CI/CD 自动化脚本" autoFocus />
|
|
||||||
</div>
|
|
||||||
<div className="form-group">
|
|
||||||
<label className="form-label">权限范围</label>
|
|
||||||
<div className="checkbox-group" style={{ maxHeight: "200px" }}>
|
|
||||||
{ALL_PERMISSIONS.map((p) => (
|
|
||||||
<label key={p.value} className="checkbox-item">
|
|
||||||
<input type="checkbox"
|
|
||||||
checked={newPerms.includes(p.value)}
|
|
||||||
onChange={(e) => {
|
|
||||||
setNewPerms(e.target.checked
|
|
||||||
? [...newPerms, p.value]
|
|
||||||
: newPerms.filter((x) => x !== p.value));
|
|
||||||
}} />
|
|
||||||
{p.label}
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="form-group">
|
|
||||||
<label className="form-label">有效期(天,留空永不过期)</label>
|
|
||||||
<input type="number" className="form-input" min="1"
|
|
||||||
value={newExpiry} onChange={(e) => setNewExpiry(e.target.value)}
|
|
||||||
placeholder="不填则永不过期" />
|
|
||||||
</div>
|
|
||||||
<div className="modal-footer">
|
|
||||||
<button className="btn btn-secondary" onClick={() => setShowCreate(false)}>取消</button>
|
|
||||||
<button className="btn btn-primary" disabled={creating} onClick={handleCreate}>
|
|
||||||
{creating ? "创建中..." : "创建"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,25 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import Sidebar, { useAdminAuth } from "@/components/admin/Sidebar";
|
|
||||||
|
|
||||||
export default function AdminLayout({
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
children: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
const { user, loading, handleLogout } = useAdminAuth();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="admin-layout">
|
|
||||||
<Sidebar user={user} loading={loading} onLogout={handleLogout} />
|
|
||||||
<main className="admin-main">
|
|
||||||
{!user && !loading && (
|
|
||||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%" }}>
|
|
||||||
<div className="loading"><span>加载中...</span></div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{(!loading || user) && children}
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,126 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { format } from "date-fns";
|
|
||||||
|
|
||||||
interface AuditLog {
|
|
||||||
id: number; user_id: number; username: string; action: string;
|
|
||||||
resource: string; resource_id: string | null; request_params: Record<string, unknown> | null;
|
|
||||||
ip_address: string | null; result: string; error_message: string | null; created_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ACTION_LABELS: Record<string, string> = {
|
|
||||||
create: "创建", read: "查看", update: "更新", delete: "删除",
|
|
||||||
login: "登录", logout: "登出",
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function LogsPage() {
|
|
||||||
const [logs, setLogs] = useState<AuditLog[]>([]);
|
|
||||||
const [total, setTotal] = useState(0);
|
|
||||||
const [page, setPage] = useState(1);
|
|
||||||
const [action, setAction] = useState("");
|
|
||||||
const [resource, setResource] = useState("");
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [detailLog, setDetailLog] = useState<AuditLog | null>(null);
|
|
||||||
const pageSize = 20;
|
|
||||||
|
|
||||||
useEffect(() => { loadLogs(); }, [page, action, resource]);
|
|
||||||
async function loadLogs() {
|
|
||||||
setLoading(true);
|
|
||||||
const params = new URLSearchParams({ page: String(page), pageSize: String(pageSize) });
|
|
||||||
if (action) params.set("action", action);
|
|
||||||
if (resource) params.set("resource", resource);
|
|
||||||
const res = await fetch(`/api/logs?${params}`);
|
|
||||||
const data = await res.json();
|
|
||||||
setLogs(data.logs || []);
|
|
||||||
setTotal(data.total || 0);
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
const totalPages = Math.ceil(total / pageSize);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="admin-content">
|
|
||||||
<div className="page-header">
|
|
||||||
<h1 className="page-title">审计日志</h1>
|
|
||||||
<p className="page-subtitle">共 {total} 条记录</p>
|
|
||||||
</div>
|
|
||||||
<div className="toolbar">
|
|
||||||
<select className="form-input" style={{ maxWidth: "160px" }} value={action}
|
|
||||||
onChange={(e) => { setAction(e.target.value); setPage(1); }}>
|
|
||||||
<option value="">全部操作</option>
|
|
||||||
{Object.entries(ACTION_LABELS).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
|
||||||
</select>
|
|
||||||
<input type="text" className="form-input search-input" placeholder="搜索资源..."
|
|
||||||
value={resource} onChange={(e) => { setResource(e.target.value); setPage(1); }} />
|
|
||||||
<button
|
|
||||||
className="btn btn-secondary"
|
|
||||||
onClick={() => {
|
|
||||||
const params = new URLSearchParams({ format: "csv" });
|
|
||||||
if (action) params.set("action", action);
|
|
||||||
if (resource) params.set("resource", resource);
|
|
||||||
window.location.href = `/api/logs?${params}`;
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
导出 CSV
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="card">
|
|
||||||
{loading ? <div className="loading">加载中...</div> : (
|
|
||||||
<div className="table-container">
|
|
||||||
<table className="data-table">
|
|
||||||
<thead><tr><th>时间</th><th>用户</th><th>操作</th><th>资源</th><th>结果</th><th>IP</th><th>详情</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
{logs.map((log) => (
|
|
||||||
<tr key={log.id}>
|
|
||||||
<td>{format(new Date(log.created_at), "yyyy-MM-dd HH:mm:ss")}</td>
|
|
||||||
<td>{log.username}</td>
|
|
||||||
<td><span className="badge badge-neutral">{ACTION_LABELS[log.action] || log.action}</span></td>
|
|
||||||
<td>
|
|
||||||
<span style={{ fontSize: "12px", color: "#737373" }}>{log.resource}</span>
|
|
||||||
{log.resource_id && <span style={{ fontSize: "11px", color: "#a3a3a3", marginLeft: "4px" }}>#{log.resource_id}</span>}
|
|
||||||
</td>
|
|
||||||
<td><span className={`badge ${log.result === "success" ? "badge-success" : "badge-danger"}`}>{log.result === "success" ? "成功" : "失败"}</span></td>
|
|
||||||
<td style={{ fontSize: "12px" }}>{log.ip_address || "-"}</td>
|
|
||||||
<td><button className="btn btn-secondary btn-sm" onClick={() => setDetailLog(log)}>查看</button></td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
{logs.length === 0 && <tr><td colSpan={7} style={{ textAlign: "center", padding: "24px", color: "#737373" }}>暂无数据</td></tr>}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{totalPages > 1 && (
|
|
||||||
<div className="pagination">
|
|
||||||
<span className="pagination-info">第 {page} / {totalPages} 页,共 {total} 条</span>
|
|
||||||
<button className="btn btn-secondary btn-sm" disabled={page <= 1} onClick={() => setPage(p => p - 1)}>上一页</button>
|
|
||||||
<button className="btn btn-secondary btn-sm" disabled={page >= totalPages} onClick={() => setPage(p => p + 1)}>下一页</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{detailLog && (
|
|
||||||
<div className="modal-overlay" onClick={() => setDetailLog(null)}>
|
|
||||||
<div className="modal" onClick={(e) => e.stopPropagation()} style={{ maxWidth: "600px" }}>
|
|
||||||
<h2 className="modal-title">日志详情</h2>
|
|
||||||
<div style={{ display: "grid", gridTemplateColumns: "auto 1fr", gap: "8px 16px", fontSize: "14px" }}>
|
|
||||||
<span style={{ color: "#737373" }}>ID</span><span>{detailLog.id}</span>
|
|
||||||
<span style={{ color: "#737373" }}>用户</span><span>{detailLog.username}</span>
|
|
||||||
<span style={{ color: "#737373" }}>操作</span><span>{detailLog.action}</span>
|
|
||||||
<span style={{ color: "#737373" }}>资源</span><span>{detailLog.resource}</span>
|
|
||||||
<span style={{ color: "#737373" }}>资源ID</span><span>{detailLog.resource_id || "-"}</span>
|
|
||||||
<span style={{ color: "#737373" }}>结果</span><span>{detailLog.result}</span>
|
|
||||||
<span style={{ color: "#737373" }}>IP</span><span>{detailLog.ip_address || "-"}</span>
|
|
||||||
<span style={{ color: "#737373" }}>时间</span><span>{format(new Date(detailLog.created_at), "yyyy-MM-dd HH:mm:ss")}</span>
|
|
||||||
<span style={{ color: "#737373" }}>请求参数</span>
|
|
||||||
<pre style={{ background: "#f4f4f5", padding: "8px", borderRadius: "4px", fontSize: "12px", overflow: "auto" }}>
|
|
||||||
{detailLog.request_params ? JSON.stringify(detailLog.request_params, null, 2) : "-"}
|
|
||||||
</pre>
|
|
||||||
{detailLog.error_message && (<><span style={{ color: "#737373" }}>错误</span><span style={{ color: "#dc2626" }}>{detailLog.error_message}</span></>)}
|
|
||||||
</div>
|
|
||||||
<div className="modal-footer"><button className="btn btn-secondary" onClick={() => setDetailLog(null)}>关闭</button></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,163 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { format } from "date-fns";
|
|
||||||
|
|
||||||
interface MetricData {
|
|
||||||
total: number;
|
|
||||||
last_27h: number;
|
|
||||||
last_7d: number;
|
|
||||||
last_30d: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MetricsResponse {
|
|
||||||
metrics: Record<string, MetricData>;
|
|
||||||
timestamp: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ENTITY_LABELS: Record<string, { label: string; icon: string }> = {
|
|
||||||
users: { label: "用户", icon: "👤" },
|
|
||||||
workspaces: { label: "Workspace", icon: "◎" },
|
|
||||||
projects: { label: "项目", icon: "◻" },
|
|
||||||
repos: { label: "仓库", icon: "◈" },
|
|
||||||
rooms: { label: "房间", icon: "◫" },
|
|
||||||
skills: { label: "Skills", icon: "⚡" },
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function MetricsPage() {
|
|
||||||
const [data, setData] = useState<MetricsResponse | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [lastRefresh, setLastRefresh] = useState<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => { loadMetrics(); }, []);
|
|
||||||
|
|
||||||
function loadMetrics() {
|
|
||||||
setLoading(true);
|
|
||||||
fetch("/api/metrics")
|
|
||||||
.then((r) => r.json())
|
|
||||||
.then((d) => {
|
|
||||||
setData(d);
|
|
||||||
setLastRefresh(new Date().toISOString());
|
|
||||||
})
|
|
||||||
.catch(console.error)
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="admin-content">
|
|
||||||
<div className="page-header" style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start" }}>
|
|
||||||
<div>
|
|
||||||
<h1 className="page-title">平台指标</h1>
|
|
||||||
<p className="page-subtitle">
|
|
||||||
各实体数量统计
|
|
||||||
{lastRefresh && (
|
|
||||||
<span style={{ marginLeft: "8px", fontSize: "12px", color: "#737373" }}>
|
|
||||||
更新于 {format(new Date(lastRefresh), "HH:mm:ss")}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button className="btn btn-secondary" onClick={loadMetrics}>刷新</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{loading ? (
|
|
||||||
<div className="loading">加载中...</div>
|
|
||||||
) : data ? (
|
|
||||||
<>
|
|
||||||
{/* Summary cards */}
|
|
||||||
<div className="stats-grid" style={{ gridTemplateColumns: "repeat(auto-fill, minmax(200px, 1fr))" }}>
|
|
||||||
{Object.entries(data.metrics).map(([key, val]) => {
|
|
||||||
const meta = ENTITY_LABELS[key];
|
|
||||||
if (!meta) return null;
|
|
||||||
return (
|
|
||||||
<div key={key} className="stat-card">
|
|
||||||
<div className="stat-label" style={{ fontSize: "14px", marginBottom: "4px" }}>
|
|
||||||
{meta.icon} {meta.label}
|
|
||||||
</div>
|
|
||||||
<div className="stat-value" style={{ fontSize: "28px" }}>
|
|
||||||
{val.total.toLocaleString()}
|
|
||||||
</div>
|
|
||||||
<div className="stat-label">总计</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Detail table */}
|
|
||||||
<div className="card" style={{ marginTop: "16px" }}>
|
|
||||||
<div className="table-container">
|
|
||||||
<table className="data-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>实体</th>
|
|
||||||
<th>总计</th>
|
|
||||||
<th>过去 27 小时新增</th>
|
|
||||||
<th>过去 7 天新增</th>
|
|
||||||
<th>过去 30 天新增</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{Object.entries(data.metrics).map(([key, val]) => {
|
|
||||||
const meta = ENTITY_LABELS[key];
|
|
||||||
if (!meta) return null;
|
|
||||||
return (
|
|
||||||
<tr key={key}>
|
|
||||||
<td>
|
|
||||||
<span style={{ fontWeight: 500 }}>{meta.icon} {meta.label}</span>
|
|
||||||
</td>
|
|
||||||
<td style={{ fontWeight: 600 }}>{val.total.toLocaleString()}</td>
|
|
||||||
<td>
|
|
||||||
<span className="badge badge-neutral">
|
|
||||||
+{val.last_27h.toLocaleString()}
|
|
||||||
</span>
|
|
||||||
{val.last_27h > 0 && (
|
|
||||||
<span style={{ marginLeft: "6px", fontSize: "12px", color: "#16a34a" }}>
|
|
||||||
({((val.last_27h / Math.max(val.total, 1)) * 100).toFixed(1)}%)
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<span className="badge badge-neutral">
|
|
||||||
+{val.last_7d.toLocaleString()}
|
|
||||||
</span>
|
|
||||||
{val.last_7d > 0 && (
|
|
||||||
<span style={{ marginLeft: "6px", fontSize: "12px", color: "#16a34a" }}>
|
|
||||||
({((val.last_7d / Math.max(val.total, 1)) * 100).toFixed(1)}%)
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<span className="badge badge-neutral">
|
|
||||||
+{val.last_30d.toLocaleString()}
|
|
||||||
</span>
|
|
||||||
{val.last_30d > 0 && (
|
|
||||||
<span style={{ marginLeft: "6px", fontSize: "12px", color: "#16a34a" }}>
|
|
||||||
({((val.last_30d / Math.max(val.total, 1)) * 100).toFixed(1)}%)
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Raw JSON for API consumers */}
|
|
||||||
<div className="card" style={{ marginTop: "16px" }}>
|
|
||||||
<h3 style={{ margin: "0 0 8px 0", fontSize: "14px", fontWeight: 600 }}>API 端点</h3>
|
|
||||||
<p style={{ margin: "0 0 8px 0", fontSize: "13px", color: "#737373" }}>
|
|
||||||
通过 <code>GET /api/metrics</code> 获取 JSON 格式指标数据,可用于 Prometheus 或其他监控系统采集。
|
|
||||||
</p>
|
|
||||||
<pre style={{ background: "#1e1e1e", color: "#d4d4d4", padding: "16px", borderRadius: "8px", fontSize: "12px", overflow: "auto" }}>
|
|
||||||
{JSON.stringify(data, null, 2)}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className="alert alert-error">无法加载指标数据</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,93 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useState, FormEvent } from "react";
|
|
||||||
|
|
||||||
interface Permission {
|
|
||||||
id: number; name: string; code: string; description: string | null; created_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function PermissionsPage() {
|
|
||||||
const [permissions, setPermissions] = useState<Permission[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [showCreate, setShowCreate] = useState(false);
|
|
||||||
const [form, setForm] = useState({ name: "", code: "", description: "" });
|
|
||||||
const [error, setError] = useState("");
|
|
||||||
|
|
||||||
useEffect(() => { loadPermissions(); }, []);
|
|
||||||
async function loadPermissions() {
|
|
||||||
setLoading(true);
|
|
||||||
const res = await fetch("/api/permissions");
|
|
||||||
const data = await res.json();
|
|
||||||
setPermissions(data.permissions || []);
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleCreate(e: FormEvent) {
|
|
||||||
e.preventDefault();
|
|
||||||
setError("");
|
|
||||||
const res = await fetch("/api/permissions", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(form) });
|
|
||||||
const data = await res.json();
|
|
||||||
if (!res.ok) { setError(data.error || "创建失败"); return; }
|
|
||||||
setShowCreate(false);
|
|
||||||
setForm({ name: "", code: "", description: "" });
|
|
||||||
loadPermissions();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleDelete(perm: Permission) {
|
|
||||||
if (!confirm(`删除权限 "${perm.name}" 吗?`)) return;
|
|
||||||
await fetch(`/api/permissions?id=${perm.id}`, { method: "DELETE" });
|
|
||||||
loadPermissions();
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="admin-content">
|
|
||||||
<div className="page-header" style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start" }}>
|
|
||||||
<div><h1 className="page-title">权限管理</h1><p className="page-subtitle">共 {permissions.length} 个权限</p></div>
|
|
||||||
<button className="btn btn-primary" onClick={() => setShowCreate(true)}>+ 新建权限</button>
|
|
||||||
</div>
|
|
||||||
<div className="card">
|
|
||||||
{loading ? <div className="loading">加载中...</div> : (
|
|
||||||
<div className="table-container">
|
|
||||||
<table className="data-table">
|
|
||||||
<thead><tr><th>ID</th><th>名称</th><th>代码</th><th>描述</th><th>操作</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
{permissions.map((p) => (
|
|
||||||
<tr key={p.id}>
|
|
||||||
<td>{p.id}</td><td>{p.name}</td>
|
|
||||||
<td><code style={{ background: "#f4f4f5", padding: "2px 6px", borderRadius: "4px", fontSize: "12px" }}>{p.code}</code></td>
|
|
||||||
<td>{p.description || "-"}</td>
|
|
||||||
<td><button className="btn btn-danger btn-sm" onClick={() => handleDelete(p)}>删除</button></td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
{permissions.length === 0 && <tr><td colSpan={5} style={{ textAlign: "center", padding: "24px", color: "#737373" }}>暂无数据</td></tr>}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{showCreate && (
|
|
||||||
<div className="modal-overlay" onClick={() => setShowCreate(false)}>
|
|
||||||
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
|
||||||
<h2 className="modal-title">新建权限</h2>
|
|
||||||
{error && <div className="alert alert-error">{error}</div>}
|
|
||||||
<form onSubmit={handleCreate}>
|
|
||||||
<div className="form-group"><label className="form-label">名称</label>
|
|
||||||
<input type="text" className="form-input" value={form.name}
|
|
||||||
onChange={(e) => setForm({ ...form, name: e.target.value })} required /></div>
|
|
||||||
<div className="form-group"><label className="form-label">代码 (唯一)</label>
|
|
||||||
<input type="text" className="form-input" placeholder="如: user:read" value={form.code}
|
|
||||||
onChange={(e) => setForm({ ...form, code: e.target.value.toLowerCase().replace(/[^a-z0-9_:]/g, "") })} required /></div>
|
|
||||||
<div className="form-group"><label className="form-label">描述</label>
|
|
||||||
<input type="text" className="form-input" value={form.description}
|
|
||||||
onChange={(e) => setForm({ ...form, description: e.target.value })} /></div>
|
|
||||||
<div className="modal-footer">
|
|
||||||
<button type="button" className="btn btn-secondary" onClick={() => setShowCreate(false)}>取消</button>
|
|
||||||
<button type="submit" className="btn btn-primary">创建</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,400 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useParams } from "next/navigation";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { format } from "date-fns";
|
|
||||||
|
|
||||||
interface Project {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
display_name: string;
|
|
||||||
description: string | null;
|
|
||||||
is_public: boolean;
|
|
||||||
workspace_id: string | null;
|
|
||||||
workspace_name: string | null;
|
|
||||||
workspace_slug: string | null;
|
|
||||||
workspace_plan: string | null;
|
|
||||||
created_by: string;
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
balance: string;
|
|
||||||
currency: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Member {
|
|
||||||
id?: number;
|
|
||||||
uid: string;
|
|
||||||
username: string;
|
|
||||||
display_name: string | null;
|
|
||||||
avatar_url: string | null;
|
|
||||||
role: string;
|
|
||||||
joined_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface BillingEntry {
|
|
||||||
reason: string;
|
|
||||||
amount: string;
|
|
||||||
description: string | null;
|
|
||||||
created_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ROLE_LABELS: Record<string, string> = {
|
|
||||||
owner: "所有者", admin: "管理员", member: "成员",
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function ProjectDetailPage() {
|
|
||||||
const { id } = useParams<{ id: string }>();
|
|
||||||
const [project, setProject] = useState<Project | null>(null);
|
|
||||||
const [members, setMembers] = useState<Member[]>([]);
|
|
||||||
const [billing, setBilling] = useState<{ balance: string; currency: string } | null>(null);
|
|
||||||
const [billingHistory, setBillingHistory] = useState<BillingEntry[]>([]);
|
|
||||||
const [tab, setTab] = useState<"members" | "billing">("members");
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState("");
|
|
||||||
const [showCredit, setShowCredit] = useState(false);
|
|
||||||
const [creditAmount, setCreditAmount] = useState("");
|
|
||||||
const [creditDesc, setCreditDesc] = useState("");
|
|
||||||
const [creditLoading, setCreditLoading] = useState(false);
|
|
||||||
const [creditError, setCreditError] = useState("");
|
|
||||||
|
|
||||||
// Member management
|
|
||||||
const [showAddMember, setShowAddMember] = useState(false);
|
|
||||||
const [addUserId, setAddUserId] = useState("");
|
|
||||||
const [addUserDisplay, setAddUserDisplay] = useState(""); // shown in the input field
|
|
||||||
const [addScope, setAddScope] = useState("member");
|
|
||||||
const [addMemberLoading, setAddMemberLoading] = useState(false);
|
|
||||||
const [addMemberError, setAddMemberError] = useState("");
|
|
||||||
const [searchUsers, setSearchUsers] = useState<{ uid: string; username: string; display_name: string | null }[]>([]);
|
|
||||||
const [searchLoading, setSearchLoading] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!id) return;
|
|
||||||
setLoading(true);
|
|
||||||
fetch(`/api/admin/projects/${id}`)
|
|
||||||
.then(r => r.json())
|
|
||||||
.then(data => {
|
|
||||||
if (data.error) { setError(data.error); return; }
|
|
||||||
setProject(data.project);
|
|
||||||
setMembers(data.members || []);
|
|
||||||
setBilling(data.billing || null);
|
|
||||||
setBillingHistory(data.billingHistory || []);
|
|
||||||
})
|
|
||||||
.catch(e => setError(String(e)))
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
}, [id]);
|
|
||||||
|
|
||||||
function reloadBilling() {
|
|
||||||
if (!id) return;
|
|
||||||
fetch(`/api/admin/projects/${id}/billing`)
|
|
||||||
.then(r => r.json())
|
|
||||||
.then(data => {
|
|
||||||
if (data.billing) setBilling(data.billing);
|
|
||||||
if (data.history) setBillingHistory(data.history);
|
|
||||||
})
|
|
||||||
.catch(console.error);
|
|
||||||
}
|
|
||||||
|
|
||||||
function reload() {
|
|
||||||
if (!id) return;
|
|
||||||
setLoading(true);
|
|
||||||
fetch(`/api/admin/projects/${id}`)
|
|
||||||
.then(r => r.json())
|
|
||||||
.then(data => {
|
|
||||||
if (data.error) { setError(data.error); return; }
|
|
||||||
setProject(data.project);
|
|
||||||
setMembers(data.members || []);
|
|
||||||
setBilling(data.billing || null);
|
|
||||||
setBillingHistory(data.billingHistory || []);
|
|
||||||
})
|
|
||||||
.catch(e => setError(String(e)))
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleUserSearch(q: string) {
|
|
||||||
setAddUserId(q); // raw typed text for form submission
|
|
||||||
if (q.length < 2) { setSearchUsers([]); return; }
|
|
||||||
setSearchLoading(true);
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/platform/users?search=${encodeURIComponent(q)}&pageSize=10`);
|
|
||||||
const data = await res.json();
|
|
||||||
if (!res.ok) {
|
|
||||||
setSearchUsers([]);
|
|
||||||
setAddMemberError(data.error || "搜索失败,请重试");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const existingIds = new Set(members.map(m => m.uid));
|
|
||||||
setSearchUsers((data.users || []).filter((u: { uid: string }) => !existingIds.has(u.uid)));
|
|
||||||
setAddMemberError("");
|
|
||||||
} catch {
|
|
||||||
setSearchUsers([]);
|
|
||||||
setAddMemberError("搜索失败,请检查网络连接");
|
|
||||||
}
|
|
||||||
finally { setSearchLoading(false); }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleAddMember() {
|
|
||||||
if (!addUserId) { setAddMemberError("请选择用户"); return; }
|
|
||||||
setAddMemberLoading(true);
|
|
||||||
setAddMemberError("");
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/admin/projects/${id}/members`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ userId: addUserId, scope: addScope }),
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
if (!res.ok) { setAddMemberError(data.error || "添加失败"); return; }
|
|
||||||
setShowAddMember(false);
|
|
||||||
setAddUserId("");
|
|
||||||
setAddUserDisplay("");
|
|
||||||
setAddScope("member");
|
|
||||||
setSearchUsers([]);
|
|
||||||
reload();
|
|
||||||
} catch { setAddMemberError("添加失败"); }
|
|
||||||
finally { setAddMemberLoading(false); }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleRemoveMember(memberId: number) {
|
|
||||||
if (!confirm("确定移除该成员?")) return;
|
|
||||||
try {
|
|
||||||
await fetch(`/api/admin/projects/${id}/members/${memberId}`, { method: "DELETE" });
|
|
||||||
reload();
|
|
||||||
} catch { window.alert("移除失败"); }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleUpdateScope(memberId: number, scope: string) {
|
|
||||||
try {
|
|
||||||
await fetch(`/api/admin/projects/${id}/members/${memberId}`, {
|
|
||||||
method: "PATCH",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ scope }),
|
|
||||||
});
|
|
||||||
reload();
|
|
||||||
} catch { window.alert("更新失败"); }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleAddCredit() {
|
|
||||||
const amt = parseFloat(creditAmount);
|
|
||||||
if (!amt || amt <= 0) { setCreditError("请输入有效金额"); return; }
|
|
||||||
setCreditLoading(true);
|
|
||||||
setCreditError("");
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/admin/projects/${id}/billing`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ amount: amt, description: creditDesc }),
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
if (!res.ok) { setCreditError(data.error || "充值失败"); return; }
|
|
||||||
setShowCredit(false);
|
|
||||||
setCreditAmount("");
|
|
||||||
setCreditDesc("");
|
|
||||||
reloadBilling();
|
|
||||||
} catch { setCreditError("充值失败"); }
|
|
||||||
finally { setCreditLoading(false); }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (loading) return <div className="admin-content"><div className="loading">加载中...</div></div>;
|
|
||||||
if (error || !project) return <div className="admin-content"><div className="alert alert-error">{error || "未找到"}</div></div>;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="admin-content">
|
|
||||||
<div style={{ marginBottom: "16px" }}>
|
|
||||||
<Link href="/admin/projects" className="btn btn-secondary btn-sm">← 返回列表</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="page-header">
|
|
||||||
<h1 className="page-title">{project.display_name || project.name}</h1>
|
|
||||||
<p className="page-subtitle">
|
|
||||||
{project.workspace_name && (
|
|
||||||
<Link href={`/admin/workspaces/${project.workspace_id}`} style={{ marginRight: "8px" }}>
|
|
||||||
<span className="badge badge-neutral">{project.workspace_name}</span>
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
<span className={`badge ${project.is_public ? "badge-success" : "badge-neutral"}`}>
|
|
||||||
{project.is_public ? "公开" : "私有"}
|
|
||||||
</span>
|
|
||||||
{project.description && (
|
|
||||||
<span style={{ marginLeft: "8px", fontSize: "13px", color: "#737373" }}>{project.description}</span>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="stats-grid">
|
|
||||||
<div className="stat-card">
|
|
||||||
<div className="stat-value">{members.length}</div>
|
|
||||||
<div className="stat-label">成员</div>
|
|
||||||
</div>
|
|
||||||
<div className="stat-card">
|
|
||||||
<div className="stat-value">
|
|
||||||
{billing
|
|
||||||
? `${parseFloat(billing.balance).toFixed(4)} ${billing.currency}`
|
|
||||||
: "—"}
|
|
||||||
</div>
|
|
||||||
<div className="stat-label">
|
|
||||||
项目余额
|
|
||||||
<button className="btn btn-secondary btn-sm" style={{ marginLeft: "8px" }} onClick={() => setShowCredit(true)}>+ 充值</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="stat-card">
|
|
||||||
<div className="stat-value" style={{ fontSize: "18px" }}>{project.created_by}</div>
|
|
||||||
<div className="stat-label">创建者 UID</div>
|
|
||||||
</div>
|
|
||||||
<div className="stat-card">
|
|
||||||
<div className="stat-value" style={{ fontSize: "16px" }}>{format(new Date(project.created_at), "yyyy-MM-dd")}</div>
|
|
||||||
<div className="stat-label">创建时间</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="toolbar" style={{ marginBottom: "0" }}>
|
|
||||||
<button className={`btn btn-sm ${tab === "members" ? "btn-primary" : "btn-secondary"}`} onClick={() => setTab("members")}>
|
|
||||||
成员 ({members.length})
|
|
||||||
</button>
|
|
||||||
<button className={`btn btn-sm ${tab === "billing" ? "btn-primary" : "btn-secondary"}`} onClick={() => setTab("billing")}>
|
|
||||||
账单历史
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="card">
|
|
||||||
{tab === "members" && (
|
|
||||||
<>
|
|
||||||
<div style={{ padding: "12px", borderBottom: "1px solid #e5e5e5", display: "flex", justifyContent: "flex-end" }}>
|
|
||||||
<button className="btn btn-sm btn-primary" onClick={() => setShowAddMember(true)}>+ 添加成员</button>
|
|
||||||
</div>
|
|
||||||
<div className="table-container">
|
|
||||||
<table className="data-table">
|
|
||||||
<thead><tr><th>用户名</th><th>显示名</th><th>角色</th><th>加入时间</th><th>操作</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
{members.map(m => (
|
|
||||||
<tr key={m.id ?? m.uid}>
|
|
||||||
<td>{m.username}</td>
|
|
||||||
<td>{m.display_name || "—"}</td>
|
|
||||||
<td>
|
|
||||||
{m.role === "owner" ? (
|
|
||||||
<span className="badge badge-success">{ROLE_LABELS[m.role] || m.role}</span>
|
|
||||||
) : (
|
|
||||||
<select
|
|
||||||
className="form-input"
|
|
||||||
style={{ padding: "2px 6px", fontSize: "12px", width: "auto" }}
|
|
||||||
value={m.role}
|
|
||||||
onChange={(e) => m.id && handleUpdateScope(m.id, e.target.value)}
|
|
||||||
>
|
|
||||||
<option value="admin">管理员</option>
|
|
||||||
<option value="member">成员</option>
|
|
||||||
</select>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td>{format(new Date(m.joined_at), "yyyy-MM-dd")}</td>
|
|
||||||
<td>
|
|
||||||
{m.role !== "owner" && (
|
|
||||||
<button className="btn btn-danger btn-sm" onClick={() => m.id && handleRemoveMember(m.id)}>移除</button>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
{members.length === 0 && <tr><td colSpan={5} style={{ textAlign: "center", padding: "24px", color: "#737373" }}>暂无成员</td></tr>}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{tab === "billing" && (
|
|
||||||
<div className="table-container">
|
|
||||||
<table className="data-table">
|
|
||||||
<thead><tr><th>时间</th><th>类型</th><th>金额</th><th>描述</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
{billingHistory.map((b, i) => (
|
|
||||||
<tr key={i}>
|
|
||||||
<td>{format(new Date(b.created_at), "yyyy-MM-dd HH:mm")}</td>
|
|
||||||
<td><span className="badge badge-neutral">{b.reason}</span></td>
|
|
||||||
<td style={{ color: parseFloat(b.amount) >= 0 ? "#16a34a" : "#dc2626" }}>
|
|
||||||
{parseFloat(b.amount) >= 0 ? "+" : ""}{parseFloat(b.amount).toFixed(6)}
|
|
||||||
</td>
|
|
||||||
<td style={{ fontSize: "12px", color: "#737373" }}>{b.description || "—"}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
{billingHistory.length === 0 && <tr><td colSpan={4} style={{ textAlign: "center", padding: "24px", color: "#737373" }}>暂无账单记录</td></tr>}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showCredit && (
|
|
||||||
<div className="modal-overlay" onClick={() => setShowCredit(false)}>
|
|
||||||
<div className="modal" onClick={e => e.stopPropagation()} style={{ maxWidth: "420px" }}>
|
|
||||||
<h2 className="modal-title">为项目充值</h2>
|
|
||||||
{creditError && <div className="alert alert-error">{creditError}</div>}
|
|
||||||
<div className="form-group">
|
|
||||||
<label className="form-label">充值金额(USD)</label>
|
|
||||||
<input type="number" className="form-input" min="0.01" step="0.01"
|
|
||||||
value={creditAmount} onChange={e => setCreditAmount(e.target.value)}
|
|
||||||
placeholder="例如:10.00" autoFocus />
|
|
||||||
</div>
|
|
||||||
<div className="form-group">
|
|
||||||
<label className="form-label">备注(可选)</label>
|
|
||||||
<input type="text" className="form-input"
|
|
||||||
value={creditDesc} onChange={e => setCreditDesc(e.target.value)}
|
|
||||||
placeholder="充值原因" />
|
|
||||||
</div>
|
|
||||||
<div className="modal-footer">
|
|
||||||
<button className="btn btn-secondary" onClick={() => setShowCredit(false)}>取消</button>
|
|
||||||
<button className="btn btn-primary" disabled={creditLoading} onClick={handleAddCredit}>
|
|
||||||
{creditLoading ? "充值中..." : "确认充值"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showAddMember && (
|
|
||||||
<div className="modal-overlay" onClick={() => setShowAddMember(false)}>
|
|
||||||
<div className="modal" onClick={e => e.stopPropagation()} style={{ maxWidth: "440px" }}>
|
|
||||||
<h2 className="modal-title">添加成员</h2>
|
|
||||||
{addMemberError && <div className="alert alert-error">{addMemberError}</div>}
|
|
||||||
<div className="form-group">
|
|
||||||
<label className="form-label">搜索用户</label>
|
|
||||||
<input
|
|
||||||
className="form-input"
|
|
||||||
value={addUserDisplay || addUserId}
|
|
||||||
onChange={e => { setAddUserDisplay(e.target.value); handleUserSearch(e.target.value); }}
|
|
||||||
placeholder="输入用户名搜索..."
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
{searchUsers.length > 0 && (
|
|
||||||
<div style={{ border: "1px solid #e5e5e5", borderRadius: "6px", marginTop: "4px", maxHeight: "200px", overflowY: "auto", background: "#fff" }}>
|
|
||||||
{searchUsers.map(u => (
|
|
||||||
<div
|
|
||||||
key={u.uid}
|
|
||||||
style={{ padding: "8px 12px", cursor: "pointer", borderBottom: "1px solid #f0f0f0" }}
|
|
||||||
onClick={() => { setAddUserId(u.uid); setAddUserDisplay(u.username); setSearchUsers([]); }}
|
|
||||||
>
|
|
||||||
<div style={{ fontWeight: 500, fontSize: "14px" }}>{u.username}</div>
|
|
||||||
<div style={{ fontSize: "12px", color: "#737373" }}>{u.display_name || ""}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{searchLoading && <div style={{ fontSize: "12px", color: "#737373", marginTop: "4px" }}>搜索中...</div>}
|
|
||||||
</div>
|
|
||||||
<div className="form-group">
|
|
||||||
<label className="form-label">角色</label>
|
|
||||||
<select className="form-input" value={addScope} onChange={e => setAddScope(e.target.value)}>
|
|
||||||
<option value="admin">管理员</option>
|
|
||||||
<option value="member">成员</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className="modal-footer">
|
|
||||||
<button className="btn btn-secondary" onClick={() => { setShowAddMember(false); setSearchUsers([]); setAddUserDisplay(""); }}>取消</button>
|
|
||||||
<button className="btn btn-primary" disabled={addMemberLoading || !addUserId} onClick={handleAddMember}>
|
|
||||||
{addMemberLoading ? "添加中..." : "添加"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,156 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { format } from "date-fns";
|
|
||||||
|
|
||||||
interface Project {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
display_name: string;
|
|
||||||
description: string | null;
|
|
||||||
is_public: boolean;
|
|
||||||
workspace_id: string | null;
|
|
||||||
workspace_name: string | null;
|
|
||||||
workspace_slug: string | null;
|
|
||||||
balance: string;
|
|
||||||
currency: string;
|
|
||||||
member_count: number;
|
|
||||||
created_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Workspace {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
slug: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ProjectsPage() {
|
|
||||||
const [projects, setProjects] = useState<Project[]>([]);
|
|
||||||
const [workspaces, setWorkspaces] = useState<Workspace[]>([]);
|
|
||||||
const [total, setTotal] = useState(0);
|
|
||||||
const [page, setPage] = useState(1);
|
|
||||||
const [search, setSearch] = useState("");
|
|
||||||
const [workspaceId, setWorkspaceId] = useState("");
|
|
||||||
const [visibility, setVisibility] = useState("");
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const pageSize = 20;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetch("/api/platform/workspaces?pageSize=100")
|
|
||||||
.then(r => r.json())
|
|
||||||
.then(d => setWorkspaces(d.workspaces || []))
|
|
||||||
.catch(console.error);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => { loadProjects(); }, [page, search, workspaceId, visibility]);
|
|
||||||
|
|
||||||
function loadProjects() {
|
|
||||||
setLoading(true);
|
|
||||||
const params = new URLSearchParams({ page: String(page), pageSize: String(pageSize) });
|
|
||||||
if (search) params.set("search", search);
|
|
||||||
if (workspaceId) params.set("workspaceId", workspaceId);
|
|
||||||
if (visibility) params.set("visibility", visibility);
|
|
||||||
fetch(`/api/admin/projects?${params}`)
|
|
||||||
.then(r => r.json())
|
|
||||||
.then(data => {
|
|
||||||
setProjects(data.projects || []);
|
|
||||||
setTotal(data.total || 0);
|
|
||||||
})
|
|
||||||
.catch(console.error)
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
}
|
|
||||||
|
|
||||||
const totalPages = Math.ceil(total / pageSize);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="admin-content">
|
|
||||||
<div className="page-header">
|
|
||||||
<h1 className="page-title">项目管理</h1>
|
|
||||||
<p className="page-subtitle">共 {total} 个项目</p>
|
|
||||||
</div>
|
|
||||||
<div className="toolbar">
|
|
||||||
<input
|
|
||||||
type="text" className="form-input search-input"
|
|
||||||
placeholder="搜索项目名称..."
|
|
||||||
value={search} onChange={e => { setSearch(e.target.value); setPage(1); }}
|
|
||||||
/>
|
|
||||||
<select className="form-input" style={{ maxWidth: "200px" }}
|
|
||||||
value={workspaceId} onChange={e => { setWorkspaceId(e.target.value); setPage(1); }}>
|
|
||||||
<option value="">全部 Workspace</option>
|
|
||||||
{workspaces.map(w => (
|
|
||||||
<option key={w.id} value={w.id}>{w.name}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<select className="form-input" style={{ maxWidth: "140px" }}
|
|
||||||
value={visibility} onChange={e => { setVisibility(e.target.value); setPage(1); }}>
|
|
||||||
<option value="">全部可见性</option>
|
|
||||||
<option value="public">公开</option>
|
|
||||||
<option value="private">私有</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className="card">
|
|
||||||
{loading ? <div className="loading">加载中...</div> : (
|
|
||||||
<div className="table-container">
|
|
||||||
<table className="data-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>名称</th>
|
|
||||||
<th>Workspace</th>
|
|
||||||
<th>可见性</th>
|
|
||||||
<th>余额</th>
|
|
||||||
<th>成员</th>
|
|
||||||
<th>创建时间</th>
|
|
||||||
<th>操作</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{projects.map(p => (
|
|
||||||
<tr key={p.id}>
|
|
||||||
<td>
|
|
||||||
<div style={{ fontWeight: 500 }}>{p.display_name || p.name}</div>
|
|
||||||
{p.description && <div style={{ fontSize: "12px", color: "#737373" }}>{p.description}</div>}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{p.workspace_name
|
|
||||||
? <span style={{ fontSize: "13px" }}>{p.workspace_name}</span>
|
|
||||||
: <span style={{ color: "#a3a3a3", fontSize: "13px" }}>无</span>
|
|
||||||
}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<span className={`badge ${p.is_public ? "badge-success" : "badge-neutral"}`}>
|
|
||||||
{p.is_public ? "公开" : "私有"}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{parseFloat(p.balance) > 0
|
|
||||||
? `${parseFloat(p.balance).toFixed(4)} ${p.currency}`
|
|
||||||
: "—"}
|
|
||||||
</td>
|
|
||||||
<td>{p.member_count}</td>
|
|
||||||
<td>{format(new Date(p.created_at), "yyyy-MM-dd")}</td>
|
|
||||||
<td>
|
|
||||||
<Link href={`/admin/projects/${p.id}`}>
|
|
||||||
<button className="btn btn-secondary btn-sm">详情</button>
|
|
||||||
</Link>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
{projects.length === 0 && (
|
|
||||||
<tr><td colSpan={7} style={{ textAlign: "center", padding: "24px", color: "#737373" }}>暂无数据</td></tr>
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{totalPages > 1 && (
|
|
||||||
<div className="pagination">
|
|
||||||
<span className="pagination-info">第 {page} / {totalPages} 页,共 {total} 条</span>
|
|
||||||
<button className="btn btn-secondary btn-sm" disabled={page <= 1} onClick={() => setPage(p => p - 1)}>上一页</button>
|
|
||||||
<button className="btn btn-secondary btn-sm" disabled={page >= totalPages} onClick={() => setPage(p => p + 1)}>下一页</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,176 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useParams } from "next/navigation";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { format, parseISO } from "date-fns";
|
|
||||||
|
|
||||||
interface Branch {
|
|
||||||
name: string;
|
|
||||||
oid: string;
|
|
||||||
isHead: boolean;
|
|
||||||
upstream: string | null;
|
|
||||||
createdAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Commit {
|
|
||||||
id: number;
|
|
||||||
oid: string;
|
|
||||||
shortOid: string;
|
|
||||||
authorName: string;
|
|
||||||
authorEmail: string;
|
|
||||||
message: string;
|
|
||||||
shortMessage: string;
|
|
||||||
createdAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Repo {
|
|
||||||
id: string;
|
|
||||||
repoName: string;
|
|
||||||
projectId: string;
|
|
||||||
projectName: string;
|
|
||||||
workspaceName: string;
|
|
||||||
defaultBranch: string;
|
|
||||||
isPrivate: boolean;
|
|
||||||
description: string | null;
|
|
||||||
createdBy: string;
|
|
||||||
createdAt: string;
|
|
||||||
aiCodeReviewEnabled: boolean;
|
|
||||||
collaboratorCount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
type Tab = "branches" | "commits";
|
|
||||||
|
|
||||||
export default function RepoDetailPage() {
|
|
||||||
const { id } = useParams<{ id: string }>();
|
|
||||||
const [repo, setRepo] = useState<Repo | null>(null);
|
|
||||||
const [branches, setBranches] = useState<Branch[]>([]);
|
|
||||||
const [commits, setCommits] = useState<Commit[]>([]);
|
|
||||||
const [tab, setTab] = useState<Tab>("branches");
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState("");
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!id) return;
|
|
||||||
fetch(`/api/admin/repos/${id}`)
|
|
||||||
.then((r) => r.json())
|
|
||||||
.then((data) => {
|
|
||||||
if (data.error) { setError(data.error); return; }
|
|
||||||
setRepo(data.repo);
|
|
||||||
setBranches(data.branches || []);
|
|
||||||
setCommits(data.commits || []);
|
|
||||||
})
|
|
||||||
.catch((e) => setError(String(e)))
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
}, [id]);
|
|
||||||
|
|
||||||
if (loading) return <div className="admin-content"><div className="loading">加载中...</div></div>;
|
|
||||||
if (error) return <div className="admin-content"><div className="alert alert-error">{error}</div></div>;
|
|
||||||
if (!repo) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="admin-content">
|
|
||||||
<div className="page-header">
|
|
||||||
<div>
|
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: "8px", marginBottom: "4px" }}>
|
|
||||||
<Link href="/admin/repos" style={{ color: "#737373", fontSize: "13px" }}>← 仓库列表</Link>
|
|
||||||
</div>
|
|
||||||
<h1 className="page-title">
|
|
||||||
<code>{repo.repoName}</code>
|
|
||||||
</h1>
|
|
||||||
<p className="page-subtitle">
|
|
||||||
{repo.workspaceName ? `${repo.workspaceName} / ` : ""}{repo.projectName}
|
|
||||||
{" · "}
|
|
||||||
<span className={`badge ${repo.isPrivate ? "badge-neutral" : "badge-success"}`}>
|
|
||||||
{repo.isPrivate ? "私有" : "公开"}
|
|
||||||
</span>
|
|
||||||
{" · "}
|
|
||||||
默认分支: <code>{repo.defaultBranch}</code>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<span className={`badge ${repo.aiCodeReviewEnabled ? "badge-success" : "badge-neutral"}`}>
|
|
||||||
AI Review {repo.aiCodeReviewEnabled ? "启用" : "禁用"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Repo info card */}
|
|
||||||
<div className="card" style={{ marginBottom: "16px" }}>
|
|
||||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(200px, 1fr))", gap: "16px" }}>
|
|
||||||
<div>
|
|
||||||
<div style={{ fontSize: "12px", color: "#737373", marginBottom: "4px" }}>描述</div>
|
|
||||||
<div style={{ fontSize: "14px" }}>{repo.description || "无描述"}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div style={{ fontSize: "12px", color: "#737373", marginBottom: "4px" }}>创建者</div>
|
|
||||||
<div style={{ fontSize: "14px" }}><code>{repo.createdBy}</code></div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div style={{ fontSize: "12px", color: "#737373", marginBottom: "4px" }}>创建时间</div>
|
|
||||||
<div style={{ fontSize: "14px" }}>{format(parseISO(repo.createdAt), "yyyy-MM-dd HH:mm")}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div style={{ fontSize: "12px", color: "#737373", marginBottom: "4px" }}>协作者</div>
|
|
||||||
<div style={{ fontSize: "14px" }}>{repo.collaboratorCount} 人</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tabs */}
|
|
||||||
<div className="toolbar">
|
|
||||||
<button className={`btn btn-sm ${tab === "branches" ? "btn-primary" : "btn-secondary"}`} onClick={() => setTab("branches")}>
|
|
||||||
分支 ({branches.length})
|
|
||||||
</button>
|
|
||||||
<button className={`btn btn-sm ${tab === "commits" ? "btn-primary" : "btn-secondary"}`} onClick={() => setTab("commits")}>
|
|
||||||
最近提交 ({commits.length})
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="card">
|
|
||||||
{tab === "branches" && (
|
|
||||||
branches.length === 0 ? (
|
|
||||||
<div className="empty-state"><div className="empty-state-icon">🌿</div><p>暂无分支</p></div>
|
|
||||||
) : (
|
|
||||||
<div className="table-container">
|
|
||||||
<table className="data-table">
|
|
||||||
<thead><tr><th>分支名</th><th>Commit OID</th><th>HEAD</th><th>Upstream</th><th>更新时间</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
{branches.map((b) => (
|
|
||||||
<tr key={b.name}>
|
|
||||||
<td><code style={{ fontSize: "12px" }}>{b.name}</code></td>
|
|
||||||
<td><code style={{ fontSize: "11px", color: "#737373" }}>{b.oid.slice(0, 8)}</code></td>
|
|
||||||
<td>{b.isHead ? <span className="badge badge-success">HEAD</span> : null}</td>
|
|
||||||
<td style={{ fontSize: "12px", color: "#737373" }}>{b.upstream || "-"}</td>
|
|
||||||
<td style={{ fontSize: "12px" }}>{format(parseISO(b.createdAt), "yyyy-MM-dd HH:mm")}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
|
|
||||||
{tab === "commits" && (
|
|
||||||
commits.length === 0 ? (
|
|
||||||
<div className="empty-state"><div className="empty-state-icon">📝</div><p>暂无提交记录</p></div>
|
|
||||||
) : (
|
|
||||||
<div className="table-container">
|
|
||||||
<table className="data-table">
|
|
||||||
<thead><tr><th>OID</th><th>提交信息</th><th>作者</th><th>时间</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
{commits.map((c) => (
|
|
||||||
<tr key={c.id}>
|
|
||||||
<td><code style={{ fontSize: "11px", color: "#737373" }}>{c.shortOid}</code></td>
|
|
||||||
<td style={{ fontSize: "13px", maxWidth: "400px", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }} title={c.message}>{c.shortMessage}</td>
|
|
||||||
<td style={{ fontSize: "12px" }}>{c.authorName} <{c.authorEmail}></td>
|
|
||||||
<td style={{ fontSize: "12px" }}>{format(parseISO(c.createdAt), "yyyy-MM-dd HH:mm")}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,135 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { format, parseISO } from "date-fns";
|
|
||||||
|
|
||||||
interface Repo {
|
|
||||||
id: string;
|
|
||||||
repoName: string;
|
|
||||||
projectId: string;
|
|
||||||
projectName: string;
|
|
||||||
workspaceName: string;
|
|
||||||
defaultBranch: string;
|
|
||||||
isPrivate: boolean;
|
|
||||||
createdBy: string;
|
|
||||||
createdAt: string;
|
|
||||||
aiCodeReviewEnabled: boolean;
|
|
||||||
collaboratorCount: number;
|
|
||||||
branchCount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ReposPage() {
|
|
||||||
const [repos, setRepos] = useState<Repo[]>([]);
|
|
||||||
const [total, setTotal] = useState(0);
|
|
||||||
const [page, setPage] = useState(1);
|
|
||||||
const [search, setSearch] = useState("");
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const pageSize = 30;
|
|
||||||
|
|
||||||
useEffect(() => { loadRepos(); }, [page, search]);
|
|
||||||
|
|
||||||
function loadRepos() {
|
|
||||||
setLoading(true);
|
|
||||||
const params = new URLSearchParams({ page: String(page), pageSize: String(pageSize) });
|
|
||||||
if (search) params.set("search", search);
|
|
||||||
fetch(`/api/platform/repos?${params}`)
|
|
||||||
.then((r) => r.json())
|
|
||||||
.then((data) => {
|
|
||||||
setRepos(data.repos || []);
|
|
||||||
setTotal(data.total || 0);
|
|
||||||
})
|
|
||||||
.catch(console.error)
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
}
|
|
||||||
|
|
||||||
const totalPages = Math.ceil(total / pageSize);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="admin-content">
|
|
||||||
<div className="page-header">
|
|
||||||
<h1 className="page-title">仓库管理</h1>
|
|
||||||
<p className="page-subtitle">Git 仓库(只读)</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="toolbar">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="form-input search-input"
|
|
||||||
placeholder="搜索仓库名称..."
|
|
||||||
value={search}
|
|
||||||
onChange={(e) => { setSearch(e.target.value); setPage(1); }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="card">
|
|
||||||
{loading ? <div className="loading">加载中...</div> : repos.length === 0 ? (
|
|
||||||
<div className="empty-state">
|
|
||||||
<div className="empty-state-icon">📦</div>
|
|
||||||
<p>暂无仓库</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="table-container">
|
|
||||||
<table className="data-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>仓库名称</th>
|
|
||||||
<th>所属项目</th>
|
|
||||||
<th>可见性</th>
|
|
||||||
<th>默认分支</th>
|
|
||||||
<th>协作者</th>
|
|
||||||
<th>分支</th>
|
|
||||||
<th>AI Review</th>
|
|
||||||
<th>创建时间</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{repos.map((repo) => (
|
|
||||||
<tr key={repo.id}>
|
|
||||||
<td>
|
|
||||||
<Link href={`/admin/repos/${repo.id}`}>
|
|
||||||
<code style={{ fontSize: "12px", cursor: "pointer", color: "#2563eb" }}>{repo.repoName}</code>
|
|
||||||
</Link>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<span style={{ fontSize: "12px" }}>
|
|
||||||
{repo.workspaceName ? `${repo.workspaceName} / ` : ""}
|
|
||||||
{repo.projectName}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<span className={`badge ${repo.isPrivate ? "badge-neutral" : "badge-success"}`}>
|
|
||||||
{repo.isPrivate ? "私有" : "公开"}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td style={{ fontSize: "12px", fontFamily: "monospace" }}>
|
|
||||||
{repo.defaultBranch || "main"}
|
|
||||||
</td>
|
|
||||||
<td>{repo.collaboratorCount}</td>
|
|
||||||
<td>{repo.branchCount}</td>
|
|
||||||
<td>
|
|
||||||
<span className={`badge ${repo.aiCodeReviewEnabled ? "badge-success" : "badge-neutral"}`}>
|
|
||||||
{repo.aiCodeReviewEnabled ? "启用" : "禁用"}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td style={{ fontSize: "12px" }}>
|
|
||||||
{format(parseISO(repo.createdAt), "yyyy-MM-dd")}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{totalPages > 1 && (
|
|
||||||
<div className="pagination">
|
|
||||||
<span className="pagination-info">第 {page} / {totalPages} 页,共 {total} 条</span>
|
|
||||||
<button className="btn btn-secondary btn-sm" disabled={page <= 1} onClick={() => setPage(p => p - 1)}>上一页</button>
|
|
||||||
<button className="btn btn-secondary btn-sm" disabled={page >= totalPages} onClick={() => setPage(p => p + 1)}>下一页</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,138 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useState, FormEvent } from "react";
|
|
||||||
|
|
||||||
interface Permission { id: number; name: string; code: string; }
|
|
||||||
interface Role {
|
|
||||||
id: number; name: string; description: string | null; created_at: string;
|
|
||||||
permissions?: Permission[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function RolesPage() {
|
|
||||||
const [roles, setRoles] = useState<Role[]>([]);
|
|
||||||
const [permissions, setPermissions] = useState<Permission[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [showCreate, setShowCreate] = useState(false);
|
|
||||||
const [showEdit, setShowEdit] = useState<Role | null>(null);
|
|
||||||
const [form, setForm] = useState({ name: "", description: "", permissionIds: [] as number[] });
|
|
||||||
const [error, setError] = useState("");
|
|
||||||
|
|
||||||
useEffect(() => { loadData(); }, []);
|
|
||||||
async function loadData() {
|
|
||||||
const [rolesRes, permsRes] = await Promise.all([fetch("/api/roles"), fetch("/api/permissions")]);
|
|
||||||
const [rolesData, permsData] = await Promise.all([rolesRes.json(), permsRes.json()]);
|
|
||||||
setRoles(rolesData.roles || []);
|
|
||||||
setPermissions(permsData.permissions || []);
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openEdit(role: Role) {
|
|
||||||
const res = await fetch(`/api/roles/${role.id}`);
|
|
||||||
const data = await res.json();
|
|
||||||
setShowEdit(data);
|
|
||||||
setForm({ name: data.name, description: data.description || "", permissionIds: (data.permissions || []).map((p: Permission) => p.id) });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleCreate(e: FormEvent) {
|
|
||||||
e.preventDefault();
|
|
||||||
setError("");
|
|
||||||
const res = await fetch("/api/roles", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(form) });
|
|
||||||
const data = await res.json();
|
|
||||||
if (!res.ok) { setError(data.error || "创建失败"); return; }
|
|
||||||
setShowCreate(false);
|
|
||||||
loadData();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleUpdate(e: FormEvent) {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!showEdit) return;
|
|
||||||
setError("");
|
|
||||||
const res = await fetch(`/api/roles/${showEdit.id}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(form) });
|
|
||||||
const data = await res.json();
|
|
||||||
if (!res.ok) { setError(data.error || "更新失败"); return; }
|
|
||||||
setShowEdit(null);
|
|
||||||
loadData();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleDelete(role: Role) {
|
|
||||||
if (!confirm(`删除角色 "${role.name}" 吗?`)) return;
|
|
||||||
await fetch(`/api/roles/${role.id}`, { method: "DELETE" });
|
|
||||||
loadData();
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderModal(title: string, onSubmit: (e: FormEvent) => void) {
|
|
||||||
return (
|
|
||||||
<div className="modal-overlay" onClick={() => { setShowCreate(false); setShowEdit(null); }}>
|
|
||||||
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
|
||||||
<h2 className="modal-title">{title}</h2>
|
|
||||||
{error && <div className="alert alert-error">{error}</div>}
|
|
||||||
<form onSubmit={onSubmit}>
|
|
||||||
<div className="form-group">
|
|
||||||
<label className="form-label">角色名称</label>
|
|
||||||
<input type="text" className="form-input" value={form.name}
|
|
||||||
onChange={(e) => setForm({ ...form, name: e.target.value })} required />
|
|
||||||
</div>
|
|
||||||
<div className="form-group">
|
|
||||||
<label className="form-label">描述</label>
|
|
||||||
<input type="text" className="form-input" value={form.description}
|
|
||||||
onChange={(e) => setForm({ ...form, description: e.target.value })} />
|
|
||||||
</div>
|
|
||||||
<div className="form-group">
|
|
||||||
<label className="form-label">权限</label>
|
|
||||||
<div className="checkbox-group">
|
|
||||||
{permissions.map((p) => (
|
|
||||||
<label key={p.id} className="checkbox-item">
|
|
||||||
<input type="checkbox" checked={form.permissionIds.includes(p.id)}
|
|
||||||
onChange={(e) => {
|
|
||||||
const ids = e.target.checked ? [...form.permissionIds, p.id] : form.permissionIds.filter(id => id !== p.id);
|
|
||||||
setForm({ ...form, permissionIds: ids });
|
|
||||||
}} />
|
|
||||||
<span>{p.name}</span>
|
|
||||||
<span style={{ fontSize: "11px", color: "#737373", marginLeft: "4px" }}>{p.code}</span>
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="modal-footer">
|
|
||||||
<button type="button" className="btn btn-secondary" onClick={() => { setShowCreate(false); setShowEdit(null); }}>取消</button>
|
|
||||||
<button type="submit" className="btn btn-primary">保存</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="admin-content">
|
|
||||||
<div className="page-header" style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start" }}>
|
|
||||||
<div><h1 className="page-title">角色管理</h1><p className="page-subtitle">共 {roles.length} 个角色</p></div>
|
|
||||||
<button className="btn btn-primary" onClick={() => { setForm({ name: "", description: "", permissionIds: [] }); setShowCreate(true); }}>+ 新建角色</button>
|
|
||||||
</div>
|
|
||||||
<div className="card">
|
|
||||||
{loading ? <div className="loading">加载中...</div> : (
|
|
||||||
<div className="table-container">
|
|
||||||
<table className="data-table">
|
|
||||||
<thead><tr><th>ID</th><th>名称</th><th>描述</th><th>创建时间</th><th>操作</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
{roles.map((r) => (
|
|
||||||
<tr key={r.id}>
|
|
||||||
<td>{r.id}</td><td>{r.name}</td><td>{r.description || "-"}</td>
|
|
||||||
<td>{new Date(r.created_at).toLocaleDateString()}</td>
|
|
||||||
<td><div style={{ display: "flex", gap: "4px" }}>
|
|
||||||
<button className="btn btn-secondary btn-sm" onClick={() => openEdit(r)}>编辑</button>
|
|
||||||
<button className="btn btn-danger btn-sm" onClick={() => handleDelete(r)}>删除</button>
|
|
||||||
</div></td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
{roles.length === 0 && <tr><td colSpan={5} style={{ textAlign: "center", padding: "24px", color: "#737373" }}>暂无数据</td></tr>}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{showCreate && renderModal("新建角色", handleCreate)}
|
|
||||||
{showEdit && renderModal("编辑角色", handleUpdate)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,175 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useParams } from "next/navigation";
|
|
||||||
import { format, parseISO } from "date-fns";
|
|
||||||
|
|
||||||
interface Message {
|
|
||||||
id: string;
|
|
||||||
seq: number;
|
|
||||||
senderType: string;
|
|
||||||
senderId: string | null;
|
|
||||||
senderName: string | null;
|
|
||||||
content: string;
|
|
||||||
contentType: string;
|
|
||||||
sendAt: string;
|
|
||||||
revoked: string | null;
|
|
||||||
inReplyTo: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const SENDER_LABELS: Record<string, string> = {
|
|
||||||
user: "用户", member: "用户", ai: "AI", system: "系统", bot: "Bot",
|
|
||||||
};
|
|
||||||
|
|
||||||
const CONTENT_TYPE_LABELS: Record<string, string> = {
|
|
||||||
text: "文本", markdown: "Markdown", code: "代码", image: "图片",
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function RoomDetailPage() {
|
|
||||||
const { id } = useParams<{ id: string }>();
|
|
||||||
const [messages, setMessages] = useState<Message[]>([]);
|
|
||||||
const [total, setTotal] = useState(0);
|
|
||||||
const [page, setPage] = useState(1);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [revoking, setRevoking] = useState<string | null>(null);
|
|
||||||
const pageSize = 50;
|
|
||||||
|
|
||||||
useEffect(() => { loadMessages(); }, [page]);
|
|
||||||
|
|
||||||
function loadMessages() {
|
|
||||||
setLoading(true);
|
|
||||||
const params = new URLSearchParams({ page: String(page), pageSize: String(pageSize) });
|
|
||||||
fetch(`/api/platform/rooms/${id}/messages?${params}`)
|
|
||||||
.then((r) => r.json())
|
|
||||||
.then((data) => {
|
|
||||||
setMessages(data.messages || []);
|
|
||||||
setTotal(data.total || 0);
|
|
||||||
})
|
|
||||||
.catch(console.error)
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function revokeMessage(msgId: string) {
|
|
||||||
if (!confirm("确定撤回此消息?")) return;
|
|
||||||
setRevoking(msgId);
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/platform/rooms/${id}/messages/${msgId}`, { method: "DELETE" });
|
|
||||||
if (res.ok) {
|
|
||||||
setMessages((msgs) => msgs.map((m) => m.id === msgId ? { ...m, revoked: new Date().toISOString() } : m));
|
|
||||||
} else {
|
|
||||||
const data = await res.json();
|
|
||||||
alert(data.error || "撤回失败");
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setRevoking(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const totalPages = Math.ceil(total / pageSize);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="admin-content">
|
|
||||||
<div className="page-header">
|
|
||||||
<h1 className="page-title">房间消息</h1>
|
|
||||||
<p className="page-subtitle">ID: {id}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="toolbar">
|
|
||||||
<span style={{ fontSize: "13px", color: "#737373" }}>
|
|
||||||
共 {total.toLocaleString()} 条消息
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="card">
|
|
||||||
{loading ? <div className="loading">加载中...</div> : messages.length === 0 ? (
|
|
||||||
<div className="empty-state">
|
|
||||||
<div className="empty-state-icon">💬</div>
|
|
||||||
<p>暂无消息</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div style={{ maxHeight: "600px", overflow: "auto" }}>
|
|
||||||
<table className="data-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th style={{ width: "60px" }}>序号</th>
|
|
||||||
<th style={{ width: "140px" }}>发送者</th>
|
|
||||||
<th>内容</th>
|
|
||||||
<th style={{ width: "80px" }}>类型</th>
|
|
||||||
<th style={{ width: "160px" }}>时间</th>
|
|
||||||
<th style={{ width: "80px" }}>操作</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{messages.map((msg) => (
|
|
||||||
<tr key={msg.id} style={{ opacity: msg.revoked ? "0.5" : "1" }}>
|
|
||||||
<td style={{ fontSize: "11px", fontFamily: "monospace" }}>#{msg.seq}</td>
|
|
||||||
<td>
|
|
||||||
<div style={{ fontSize: "12px" }}>
|
|
||||||
<div style={{ fontWeight: 500 }}>
|
|
||||||
{msg.senderName || <span style={{ color: "#a3a3a3" }}>未知</span>}
|
|
||||||
</div>
|
|
||||||
<div style={{ color: "#737373", fontSize: "11px" }}>
|
|
||||||
{SENDER_LABELS[msg.senderType] || msg.senderType}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td style={{ maxWidth: "400px" }}>
|
|
||||||
{msg.revoked ? (
|
|
||||||
<span style={{ color: "#a3a3a3", fontStyle: "italic", fontSize: "12px" }}>
|
|
||||||
[此消息已被撤回]
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontSize: "12px",
|
|
||||||
fontFamily: msg.contentType === "code" ? "monospace" : "inherit",
|
|
||||||
display: "block",
|
|
||||||
maxWidth: "400px",
|
|
||||||
overflow: "hidden",
|
|
||||||
textOverflow: "ellipsis",
|
|
||||||
whiteSpace: "nowrap",
|
|
||||||
}}
|
|
||||||
title={msg.content}
|
|
||||||
>
|
|
||||||
{msg.content.slice(0, 200)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<span className="badge badge-neutral">
|
|
||||||
{CONTENT_TYPE_LABELS[msg.contentType] || msg.contentType}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td style={{ fontSize: "11px" }}>
|
|
||||||
{format(parseISO(msg.sendAt), "yyyy-MM-dd HH:mm:ss")}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{!msg.revoked && (
|
|
||||||
<button
|
|
||||||
className="btn btn-secondary btn-sm"
|
|
||||||
disabled={revoking === msg.id}
|
|
||||||
onClick={() => revokeMessage(msg.id)}
|
|
||||||
style={{ fontSize: "11px", padding: "2px 8px" }}
|
|
||||||
>
|
|
||||||
{revoking === msg.id ? "..." : "撤回"}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{totalPages > 1 && (
|
|
||||||
<div className="pagination">
|
|
||||||
<span className="pagination-info">第 {page} / {totalPages} 页,共 {total} 条</span>
|
|
||||||
<button className="btn btn-secondary btn-sm" disabled={page <= 1} onClick={() => setPage(p => p - 1)}>上一页</button>
|
|
||||||
<button className="btn btn-secondary btn-sm" disabled={page >= totalPages} onClick={() => setPage(p => p + 1)}>下一页</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,124 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { format, parseISO } from "date-fns";
|
|
||||||
|
|
||||||
interface Room {
|
|
||||||
id: string;
|
|
||||||
roomName: string;
|
|
||||||
isPublic: boolean;
|
|
||||||
projectId: string;
|
|
||||||
projectName: string;
|
|
||||||
workspaceName: string;
|
|
||||||
memberCount: number;
|
|
||||||
messageCount: number;
|
|
||||||
lastMsgAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function RoomsPage() {
|
|
||||||
const [rooms, setRooms] = useState<Room[]>([]);
|
|
||||||
const [total, setTotal] = useState(0);
|
|
||||||
const [page, setPage] = useState(1);
|
|
||||||
const [search, setSearch] = useState("");
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const pageSize = 30;
|
|
||||||
|
|
||||||
useEffect(() => { loadRooms(); }, [page, search]);
|
|
||||||
|
|
||||||
function loadRooms() {
|
|
||||||
setLoading(true);
|
|
||||||
const params = new URLSearchParams({ page: String(page), pageSize: String(pageSize) });
|
|
||||||
if (search) params.set("search", search);
|
|
||||||
fetch(`/api/platform/rooms?${params}`)
|
|
||||||
.then((r) => r.json())
|
|
||||||
.then((data) => {
|
|
||||||
setRooms(data.rooms || []);
|
|
||||||
setTotal(data.total || 0);
|
|
||||||
})
|
|
||||||
.catch(console.error)
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
}
|
|
||||||
|
|
||||||
const totalPages = Math.ceil(total / pageSize);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="admin-content">
|
|
||||||
<div className="page-header">
|
|
||||||
<h1 className="page-title">房间管理</h1>
|
|
||||||
<p className="page-subtitle">项目聊天室(只读)</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="toolbar">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="form-input search-input"
|
|
||||||
placeholder="搜索房间名称..."
|
|
||||||
value={search}
|
|
||||||
onChange={(e) => { setSearch(e.target.value); setPage(1); }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="card">
|
|
||||||
{loading ? <div className="loading">加载中...</div> : rooms.length === 0 ? (
|
|
||||||
<div className="empty-state">
|
|
||||||
<div className="empty-state-icon">💬</div>
|
|
||||||
<p>暂无房间</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="table-container">
|
|
||||||
<table className="data-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>房间名称</th>
|
|
||||||
<th>项目</th>
|
|
||||||
<th>可见性</th>
|
|
||||||
<th>成员</th>
|
|
||||||
<th>消息数</th>
|
|
||||||
<th>最后活跃</th>
|
|
||||||
<th>操作</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{rooms.map((room) => (
|
|
||||||
<tr key={room.id}>
|
|
||||||
<td>{room.roomName}</td>
|
|
||||||
<td>
|
|
||||||
<span style={{ fontSize: "12px" }}>
|
|
||||||
{room.workspaceName ? `${room.workspaceName} / ` : ""}
|
|
||||||
{room.projectName}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<span className={`badge ${room.isPublic ? "badge-success" : "badge-neutral"}`}>
|
|
||||||
{room.isPublic ? "公开" : "私有"}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td>{room.memberCount}</td>
|
|
||||||
<td>{room.messageCount.toLocaleString()}</td>
|
|
||||||
<td style={{ fontSize: "12px" }}>
|
|
||||||
{room.lastMsgAt ? format(parseISO(room.lastMsgAt), "yyyy-MM-dd HH:mm") : "—"}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<Link href={`/admin/rooms/${room.id}`} className="btn btn-secondary btn-sm">
|
|
||||||
查看
|
|
||||||
</Link>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{totalPages > 1 && (
|
|
||||||
<div className="pagination">
|
|
||||||
<span className="pagination-info">第 {page} / {totalPages} 页,共 {total} 条</span>
|
|
||||||
<button className="btn btn-secondary btn-sm" disabled={page <= 1} onClick={() => setPage(p => p - 1)}>上一页</button>
|
|
||||||
<button className="btn btn-secondary btn-sm" disabled={page >= totalPages} onClick={() => setPage(p => p + 1)}>下一页</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,96 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { format } from "date-fns";
|
|
||||||
|
|
||||||
interface SessionInfo {
|
|
||||||
sessionId: string; userId: string | null; username: string | null;
|
|
||||||
ipAddress: string | null; userAgent: string | null; createdAt: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function SessionsPage() {
|
|
||||||
const [sessions, setSessions] = useState<SessionInfo[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [kicking, setKicking] = useState<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => { loadSessions(); }, []);
|
|
||||||
async function loadSessions() {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const res = await fetch("/api/sessions");
|
|
||||||
const data = await res.json();
|
|
||||||
setSessions(data.sessions || []);
|
|
||||||
} catch (e) { console.error(e); }
|
|
||||||
finally { setLoading(false); }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleKickSession(sessionId: string) {
|
|
||||||
if (!confirm("确定强制下线该会话吗?")) return;
|
|
||||||
setKicking(sessionId);
|
|
||||||
try {
|
|
||||||
await fetch("/api/sessions", { method: "DELETE", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ sessionId }) });
|
|
||||||
loadSessions();
|
|
||||||
} finally { setKicking(null); }
|
|
||||||
}
|
|
||||||
|
|
||||||
const byUser = sessions.reduce<Record<string, SessionInfo[]>>((acc, s) => {
|
|
||||||
const key = s.userId || "anonymous";
|
|
||||||
if (!acc[key]) acc[key] = [];
|
|
||||||
acc[key].push(s);
|
|
||||||
return acc;
|
|
||||||
}, {});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="admin-content">
|
|
||||||
<div className="page-header" style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start" }}>
|
|
||||||
<div>
|
|
||||||
<h1 className="page-title">在线用户管理</h1>
|
|
||||||
<p className="page-subtitle">共 {sessions.length} 个活跃会话,涉及 {Object.keys(byUser).length} 个用户</p>
|
|
||||||
</div>
|
|
||||||
<button className="btn btn-secondary" onClick={loadSessions}>刷新</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{loading ? <div className="loading">加载中...</div>
|
|
||||||
: sessions.length === 0 ? (
|
|
||||||
<div className="card"><div className="empty-state"><div className="empty-state-icon">◐</div><p>当前没有在线的管理员会话</p></div></div>
|
|
||||||
) : Object.entries(byUser).map(([userId, userSessions]) => (
|
|
||||||
<div key={userId} className="card" style={{ marginBottom: "16px" }}>
|
|
||||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "12px" }}>
|
|
||||||
<div>
|
|
||||||
<span style={{ fontWeight: 600 }}>
|
|
||||||
{userId && userSessions[0].username
|
|
||||||
? `${userSessions[0].username} (ID: ${userId})`
|
|
||||||
: userId === "anonymous" ? "匿名会话" : `用户 ID: ${userId}`}
|
|
||||||
</span>
|
|
||||||
<span className="badge badge-neutral" style={{ marginLeft: "8px" }}>{userSessions.length} 个会话</span>
|
|
||||||
</div>
|
|
||||||
{userId !== "anonymous" && (
|
|
||||||
<button className="btn btn-danger btn-sm" onClick={async () => {
|
|
||||||
if (!confirm(`强制下线用户 ${userId} 的所有会话?`)) return;
|
|
||||||
for (const s of userSessions) await fetch("/api/sessions", { method: "DELETE", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ sessionId: s.sessionId }) });
|
|
||||||
loadSessions();
|
|
||||||
}}>全部下线</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="table-container">
|
|
||||||
<table className="data-table">
|
|
||||||
<thead><tr><th>Session ID</th><th>登录时间</th><th>IP地址</th><th>User Agent</th><th>操作</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
{userSessions.map((s) => (
|
|
||||||
<tr key={s.sessionId}>
|
|
||||||
<td><code style={{ fontSize: "11px", background: "#f4f4f5", padding: "2px 6px", borderRadius: "4px" }}>{s.sessionId.slice(0, 16)}...</code></td>
|
|
||||||
<td>{s.createdAt ? format(new Date(s.createdAt), "yyyy-MM-dd HH:mm") : "-"}</td>
|
|
||||||
<td style={{ fontSize: "13px" }}>{s.ipAddress || "-"}</td>
|
|
||||||
<td style={{ fontSize: "12px", maxWidth: "300px", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{s.userAgent || "-"}</td>
|
|
||||||
<td><button className="btn btn-danger btn-sm" disabled={kicking === s.sessionId} onClick={() => handleKickSession(s.sessionId)}>{kicking === s.sessionId ? "处理中..." : "下线"}</button></td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,214 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useParams, useRouter } from "next/navigation";
|
|
||||||
import Link from "next/link";
|
|
||||||
|
|
||||||
interface Role {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
description: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AdminUser {
|
|
||||||
id: number;
|
|
||||||
username: string;
|
|
||||||
is_active: boolean;
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
roles: Role[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AllRole {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
description: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function UserDetailPage() {
|
|
||||||
const { id } = useParams<{ id: string }>();
|
|
||||||
const router = useRouter();
|
|
||||||
const [user, setUser] = useState<AdminUser | null>(null);
|
|
||||||
const [allRoles, setAllRoles] = useState<AllRole[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState("");
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
const [showEditRoles, setShowEditRoles] = useState(false);
|
|
||||||
const [selectedRoles, setSelectedRoles] = useState<number[]>([]);
|
|
||||||
const [newPassword, setNewPassword] = useState("");
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!id) return;
|
|
||||||
setLoading(true);
|
|
||||||
fetch(`/api/users/${id}`)
|
|
||||||
.then((r) => r.json())
|
|
||||||
.then((data) => {
|
|
||||||
if (data.error) { setError(data.error); return; }
|
|
||||||
setUser(data);
|
|
||||||
setSelectedRoles(data.roles.map((r: Role) => r.id));
|
|
||||||
})
|
|
||||||
.catch((e) => setError(String(e)))
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
}, [id]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetch("/api/roles")
|
|
||||||
.then((r) => r.json())
|
|
||||||
.then((data) => setAllRoles(data.roles || []))
|
|
||||||
.catch(console.error);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
async function handleSave() {
|
|
||||||
setSaving(true);
|
|
||||||
setError("");
|
|
||||||
const body: Record<string, unknown> = {};
|
|
||||||
if (newPassword) body.password = newPassword;
|
|
||||||
if (showEditRoles) body.roleIds = selectedRoles;
|
|
||||||
|
|
||||||
const res = await fetch(`/api/users/${id}`, {
|
|
||||||
method: "PUT",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
if (!res.ok) { setError(data.error || "保存失败"); setSaving(false); return; }
|
|
||||||
setNewPassword("");
|
|
||||||
setShowEditRoles(false);
|
|
||||||
// reload user
|
|
||||||
const reloaded = await fetch(`/api/users/${id}`).then(r => r.json());
|
|
||||||
setUser(reloaded);
|
|
||||||
setSelectedRoles(reloaded.roles.map((r: Role) => r.id));
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (loading) return <div className="admin-content"><div className="loading">加载中...</div></div>;
|
|
||||||
if (error && !user) return <div className="admin-content"><div className="alert alert-error">{error}</div></div>;
|
|
||||||
if (!user) return <div className="admin-content"><div className="alert alert-error">用户不存在</div></div>;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="admin-content">
|
|
||||||
<div style={{ marginBottom: "16px" }}>
|
|
||||||
<Link href="/admin/users" className="btn btn-secondary btn-sm">← 返回列表</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="page-header">
|
|
||||||
<h1 className="page-title">{user.username}</h1>
|
|
||||||
<p className="page-subtitle">
|
|
||||||
<span className={`badge ${user.is_active ? "badge-success" : "badge-danger"}`}>
|
|
||||||
{user.is_active ? "启用" : "禁用"}
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && <div className="alert alert-error" style={{ marginBottom: "16px" }}>{error}</div>}
|
|
||||||
|
|
||||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "16px" }}>
|
|
||||||
{/* 基本信息 */}
|
|
||||||
<div className="card">
|
|
||||||
<div className="card-title">基本信息</div>
|
|
||||||
<table className="info-table">
|
|
||||||
<tbody>
|
|
||||||
<tr><td className="info-label">ID</td><td>{user.id}</td></tr>
|
|
||||||
<tr><td className="info-label">用户名</td><td>{user.username}</td></tr>
|
|
||||||
<tr><td className="info-label">状态</td><td>
|
|
||||||
<span className={`badge ${user.is_active ? "badge-success" : "badge-danger"}`}>
|
|
||||||
{user.is_active ? "启用" : "禁用"}
|
|
||||||
</span>
|
|
||||||
</td></tr>
|
|
||||||
<tr><td className="info-label">创建时间</td><td>{new Date(user.created_at).toLocaleString()}</td></tr>
|
|
||||||
<tr><td className="info-label">更新时间</td><td>{new Date(user.updated_at).toLocaleString()}</td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 角色 */}
|
|
||||||
<div className="card">
|
|
||||||
<div className="card-title">角色分配</div>
|
|
||||||
{user.roles.length === 0 ? (
|
|
||||||
<p style={{ color: "#737373", fontSize: "14px" }}>暂无角色</p>
|
|
||||||
) : (
|
|
||||||
<div style={{ display: "flex", flexWrap: "wrap", gap: "6px", marginBottom: "12px" }}>
|
|
||||||
{user.roles.map((r) => (
|
|
||||||
<span key={r.id} className="badge badge-neutral">{r.name}</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{showEditRoles ? (
|
|
||||||
<div className="checkbox-group" style={{ marginBottom: "12px" }}>
|
|
||||||
{allRoles.map((r) => (
|
|
||||||
<label key={r.id} className="checkbox-item">
|
|
||||||
<input type="checkbox" checked={selectedRoles.includes(r.id)}
|
|
||||||
onChange={(e) => {
|
|
||||||
setSelectedRoles(e.target.checked
|
|
||||||
? [...selectedRoles, r.id]
|
|
||||||
: selectedRoles.filter(x => x !== r.id));
|
|
||||||
}} />
|
|
||||||
{r.name}
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<div style={{ display: "flex", gap: "8px" }}>
|
|
||||||
{!showEditRoles && (
|
|
||||||
<button className="btn btn-secondary btn-sm" onClick={() => setShowEditRoles(true)}>修改角色</button>
|
|
||||||
)}
|
|
||||||
{showEditRoles && (
|
|
||||||
<>
|
|
||||||
<button className="btn btn-secondary btn-sm" onClick={() => { setShowEditRoles(false); setSelectedRoles(user.roles.map((r: Role) => r.id)); }}>取消</button>
|
|
||||||
<button className="btn btn-primary btn-sm" disabled={saving} onClick={handleSave}>
|
|
||||||
{saving ? "保存中..." : "保存角色"}
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 重置密码 */}
|
|
||||||
<div className="card">
|
|
||||||
<div className="card-title">重置密码</div>
|
|
||||||
<div className="form-group">
|
|
||||||
<label className="form-label">新密码(留空则不修改)</label>
|
|
||||||
<input type="password" className="form-input" value={newPassword}
|
|
||||||
minLength={6} onChange={(e) => setNewPassword(e.target.value)}
|
|
||||||
placeholder="至少6位" />
|
|
||||||
</div>
|
|
||||||
<button className="btn btn-primary btn-sm" disabled={!newPassword || saving}
|
|
||||||
onClick={handleSave}>
|
|
||||||
{saving ? "保存中..." : "保存密码"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 快速操作 */}
|
|
||||||
<div className="card">
|
|
||||||
<div className="card-title">快速操作</div>
|
|
||||||
<button
|
|
||||||
className={`btn btn-sm ${user.is_active ? "btn-danger" : "btn-primary"}`}
|
|
||||||
style={{ marginRight: "8px", marginBottom: "8px" }}
|
|
||||||
onClick={async () => {
|
|
||||||
const res = await fetch(`/api/users/${id}`, {
|
|
||||||
method: "PUT",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ isActive: !user!.is_active }),
|
|
||||||
});
|
|
||||||
if (res.ok) {
|
|
||||||
setUser({ ...user, is_active: !user.is_active });
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{user.is_active ? "禁用账号" : "启用账号"}
|
|
||||||
</button>
|
|
||||||
{user.id !== -1 && (
|
|
||||||
<button className="btn btn-danger btn-sm"
|
|
||||||
onClick={async () => {
|
|
||||||
if (!confirm(`确定删除用户 "${user.username}" 吗?`)) return;
|
|
||||||
const res = await fetch(`/api/users/${id}`, { method: "DELETE" });
|
|
||||||
if (res.ok) router.push("/admin/users");
|
|
||||||
}}>
|
|
||||||
删除用户
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,187 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useState, FormEvent } from "react";
|
|
||||||
import Link from "next/link";
|
|
||||||
|
|
||||||
interface AdminUser {
|
|
||||||
id: number;
|
|
||||||
username: string;
|
|
||||||
is_active: boolean;
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Role {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AdminUsersPage() {
|
|
||||||
const [users, setUsers] = useState<AdminUser[]>([]);
|
|
||||||
const [total, setTotal] = useState(0);
|
|
||||||
const [page, setPage] = useState(1);
|
|
||||||
const [search, setSearch] = useState("");
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [showCreate, setShowCreate] = useState(false);
|
|
||||||
const [createForm, setCreateForm] = useState({ username: "", password: "", roleIds: [] as number[] });
|
|
||||||
const [roles, setRoles] = useState<Role[]>([]);
|
|
||||||
const [error, setError] = useState("");
|
|
||||||
|
|
||||||
const pageSize = 20;
|
|
||||||
|
|
||||||
useEffect(() => { loadUsers(); }, [page, search]);
|
|
||||||
useEffect(() => {
|
|
||||||
fetch("/api/roles")
|
|
||||||
.then((r) => r.json())
|
|
||||||
.then((data) => setRoles(data.roles || []))
|
|
||||||
.catch(console.error);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
async function loadUsers() {
|
|
||||||
setLoading(true);
|
|
||||||
const params = new URLSearchParams({ page: String(page), pageSize: String(pageSize) });
|
|
||||||
if (search) params.set("search", search);
|
|
||||||
const res = await fetch(`/api/users?${params}`);
|
|
||||||
if (res.status === 401) {
|
|
||||||
window.location.href = "/login";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const data = await res.json();
|
|
||||||
setUsers(data.users || []);
|
|
||||||
setTotal(data.total || 0);
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleCreate(e: FormEvent) {
|
|
||||||
e.preventDefault();
|
|
||||||
setError("");
|
|
||||||
const res = await fetch("/api/users", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify(createForm),
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
if (!res.ok) { setError(data.error || "创建失败"); return; }
|
|
||||||
setShowCreate(false);
|
|
||||||
setCreateForm({ username: "", password: "", roleIds: [] });
|
|
||||||
loadUsers();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleToggleActive(u: AdminUser) {
|
|
||||||
await fetch(`/api/users/${u.id}`, {
|
|
||||||
method: "PUT",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ isActive: !u.is_active }),
|
|
||||||
});
|
|
||||||
loadUsers();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleDelete(u: AdminUser) {
|
|
||||||
if (!confirm(`确定删除 "${u.username}" 吗?`)) return;
|
|
||||||
await fetch(`/api/users/${u.id}`, { method: "DELETE" });
|
|
||||||
loadUsers();
|
|
||||||
}
|
|
||||||
|
|
||||||
const totalPages = Math.ceil(total / pageSize);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="admin-content">
|
|
||||||
<div className="page-header" style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start" }}>
|
|
||||||
<div>
|
|
||||||
<h1 className="page-title">Admin 用户管理</h1>
|
|
||||||
<p className="page-subtitle">共 {total} 个管理员用户</p>
|
|
||||||
</div>
|
|
||||||
<button className="btn btn-primary" onClick={() => setShowCreate(true)}>+ 新建用户</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="toolbar">
|
|
||||||
<input type="text" className="form-input search-input" placeholder="搜索用户名..." value={search}
|
|
||||||
onChange={(e) => { setSearch(e.target.value); setPage(1); }} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="card">
|
|
||||||
{loading ? (
|
|
||||||
<div className="loading">加载中...</div>
|
|
||||||
) : (
|
|
||||||
<div className="table-container">
|
|
||||||
<table className="data-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>ID</th><th>用户名</th><th>状态</th><th>创建时间</th><th>操作</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{users.map((u) => (
|
|
||||||
<tr key={u.id}>
|
|
||||||
<td>{u.id}</td>
|
|
||||||
<td>{u.username}</td>
|
|
||||||
<td><span className={`badge ${u.is_active ? "badge-success" : "badge-danger"}`}>{u.is_active ? "启用" : "禁用"}</span></td>
|
|
||||||
<td>{new Date(u.created_at).toLocaleDateString()}</td>
|
|
||||||
<td>
|
|
||||||
<div style={{ display: "flex", gap: "4px" }}>
|
|
||||||
<Link href={`/admin/users/${u.id}`}><button className="btn btn-secondary btn-sm">详情</button></Link>
|
|
||||||
<button className="btn btn-secondary btn-sm" onClick={() => handleToggleActive(u)}>{u.is_active ? "禁用" : "启用"}</button>
|
|
||||||
<button className="btn btn-danger btn-sm" onClick={() => handleDelete(u)}>删除</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
{users.length === 0 && (
|
|
||||||
<tr><td colSpan={5} style={{ textAlign: "center", padding: "24px", color: "#737373" }}>暂无数据</td></tr>
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{totalPages > 1 && (
|
|
||||||
<div className="pagination">
|
|
||||||
<span className="pagination-info">第 {page} / {totalPages} 页,共 {total} 条</span>
|
|
||||||
<button className="btn btn-secondary btn-sm" disabled={page <= 1} onClick={() => setPage(p => p - 1)}>上一页</button>
|
|
||||||
<button className="btn btn-secondary btn-sm" disabled={page >= totalPages} onClick={() => setPage(p => p + 1)}>下一页</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showCreate && (
|
|
||||||
<div className="modal-overlay" onClick={() => setShowCreate(false)}>
|
|
||||||
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
|
||||||
<h2 className="modal-title">新建 Admin 用户</h2>
|
|
||||||
{error && <div className="alert alert-error">{error}</div>}
|
|
||||||
<form onSubmit={handleCreate}>
|
|
||||||
<div className="form-group">
|
|
||||||
<label className="form-label">用户名</label>
|
|
||||||
<input type="text" className="form-input" value={createForm.username}
|
|
||||||
onChange={(e) => setCreateForm({ ...createForm, username: e.target.value })} required />
|
|
||||||
</div>
|
|
||||||
<div className="form-group">
|
|
||||||
<label className="form-label">密码</label>
|
|
||||||
<input type="password" className="form-input" value={createForm.password} minLength={6}
|
|
||||||
onChange={(e) => setCreateForm({ ...createForm, password: e.target.value })} required />
|
|
||||||
</div>
|
|
||||||
<div className="form-group">
|
|
||||||
<label className="form-label">角色</label>
|
|
||||||
<div className="checkbox-group">
|
|
||||||
{roles.map((r) => (
|
|
||||||
<label key={r.id} className="checkbox-item">
|
|
||||||
<input type="checkbox" checked={createForm.roleIds.includes(r.id)}
|
|
||||||
onChange={(e) => {
|
|
||||||
const ids = e.target.checked ? [...createForm.roleIds, r.id] : createForm.roleIds.filter(id => id !== r.id);
|
|
||||||
setCreateForm({ ...createForm, roleIds: ids });
|
|
||||||
}} />
|
|
||||||
{r.name}
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="modal-footer">
|
|
||||||
<button type="button" className="btn btn-secondary" onClick={() => setShowCreate(false)}>取消</button>
|
|
||||||
<button type="submit" className="btn btn-primary">创建</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,520 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useParams } from "next/navigation";
|
|
||||||
import { format } from "date-fns";
|
|
||||||
|
|
||||||
interface Workspace {
|
|
||||||
id: string; slug: string; name: string; plan: string;
|
|
||||||
description: string | null; billing_email: string | null;
|
|
||||||
balance: string | null; currency: string | null;
|
|
||||||
monthly_quota: string | null; total_spent: string | null;
|
|
||||||
created_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Member {
|
|
||||||
id?: number;
|
|
||||||
uid: string; username: string; display_name: string | null;
|
|
||||||
avatar_url: string | null; role: string; joined_at: string;
|
|
||||||
status?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Project {
|
|
||||||
id: string; name: string;
|
|
||||||
is_public: boolean; member_count: number; created_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface BillingEntry {
|
|
||||||
reason: string; amount: string; description: string | null; created_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ROLE_LABELS: Record<string, string> = {
|
|
||||||
owner: "所有者", admin: "管理员", member: "成员", viewer: "查看者",
|
|
||||||
};
|
|
||||||
const PLAN_LABELS: Record<string, string> = {
|
|
||||||
free: "免费", starter: "入门", pro: "专业", enterprise: "企业",
|
|
||||||
};
|
|
||||||
|
|
||||||
type Tab = "members" | "projects" | "billing" | "alerts";
|
|
||||||
|
|
||||||
interface AlertConfig {
|
|
||||||
id: number;
|
|
||||||
alert_type: string;
|
|
||||||
threshold: string;
|
|
||||||
email_enabled: boolean;
|
|
||||||
enabled: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ALERT_TYPE_LABELS: Record<string, { label: string; unit: string; placeholder: string }> = {
|
|
||||||
low_balance: { label: "余额不足告警", unit: "USD", placeholder: "10.00" },
|
|
||||||
monthly_quota: { label: "月度配额告警", unit: "%", placeholder: "80" },
|
|
||||||
usage_surge: { label: "使用量激增", unit: "%", placeholder: "200" },
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function WorkspaceDetailPage() {
|
|
||||||
const { id } = useParams<{ id: string }>();
|
|
||||||
const [workspace, setWorkspace] = useState<Workspace | null>(null);
|
|
||||||
const [members, setMembers] = useState<Member[]>([]);
|
|
||||||
const [projects, setProjects] = useState<Project[]>([]);
|
|
||||||
const [billingHistory, setBillingHistory] = useState<BillingEntry[]>([]);
|
|
||||||
const [tab, setTab] = useState<Tab>("members");
|
|
||||||
const [alertConfigs, setAlertConfigs] = useState<AlertConfig[]>([]);
|
|
||||||
const [alertSaving, setAlertSaving] = useState(false);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState("");
|
|
||||||
const [showCredit, setShowCredit] = useState(false);
|
|
||||||
const [creditAmount, setCreditAmount] = useState("");
|
|
||||||
const [creditDesc, setCreditDesc] = useState("");
|
|
||||||
const [creditLoading, setCreditLoading] = useState(false);
|
|
||||||
const [creditError, setCreditError] = useState("");
|
|
||||||
|
|
||||||
// Member management
|
|
||||||
const [showAddMember, setShowAddMember] = useState(false);
|
|
||||||
const [addUserId, setAddUserId] = useState("");
|
|
||||||
const [addUserDisplay, setAddUserDisplay] = useState(""); // shown in the input field
|
|
||||||
const [addRole, setAddRole] = useState("member");
|
|
||||||
const [addMemberLoading, setAddMemberLoading] = useState(false);
|
|
||||||
const [addMemberError, setAddMemberError] = useState("");
|
|
||||||
const [searchUsers, setSearchUsers] = useState<{ uid: string; username: string; display_name: string | null }[]>([]);
|
|
||||||
const [searchLoading, setSearchLoading] = useState(false);
|
|
||||||
|
|
||||||
function reload() {
|
|
||||||
if (!id) return;
|
|
||||||
setLoading(true);
|
|
||||||
Promise.all([
|
|
||||||
fetch(`/api/platform/workspaces/${id}`).then((r) => r.json()),
|
|
||||||
fetch(`/api/platform/workspaces/${id}/alert-config`).then((r) => r.json()),
|
|
||||||
]).then(([wsData, alertData]) => {
|
|
||||||
if (wsData.error) { setError(wsData.error); return; }
|
|
||||||
setWorkspace(wsData.workspace);
|
|
||||||
setMembers(wsData.members || []);
|
|
||||||
setProjects(wsData.projects || []);
|
|
||||||
setBillingHistory(wsData.billingHistory || []);
|
|
||||||
setAlertConfigs(alertData.configs || []);
|
|
||||||
}).catch((e) => setError(String(e))).finally(() => setLoading(false));
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!id) return;
|
|
||||||
reload();
|
|
||||||
}, [id]);
|
|
||||||
|
|
||||||
async function handleSaveAlerts() {
|
|
||||||
setAlertSaving(true);
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/platform/workspaces/${id}/alert-config`, {
|
|
||||||
method: "PUT",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ configs: alertConfigs }),
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
if (!res.ok) { window.alert(data.error || "保存失败"); return; }
|
|
||||||
window.alert("告警配置已保存");
|
|
||||||
} catch { window.alert("保存失败"); }
|
|
||||||
finally { setAlertSaving(false); }
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateAlert(type: string, field: keyof AlertConfig, value: string | boolean) {
|
|
||||||
setAlertConfigs((prev) => {
|
|
||||||
const existing = prev.find((c) => c.alert_type === type);
|
|
||||||
if (existing) {
|
|
||||||
return prev.map((c) => c.alert_type === type ? { ...c, [field]: value } : c);
|
|
||||||
} else {
|
|
||||||
return [...prev, {
|
|
||||||
id: 0,
|
|
||||||
alert_type: type,
|
|
||||||
threshold: "0",
|
|
||||||
email_enabled: true,
|
|
||||||
enabled: true,
|
|
||||||
[field]: value,
|
|
||||||
} as AlertConfig];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function getAlert(type: string): AlertConfig | undefined {
|
|
||||||
return alertConfigs.find((c) => c.alert_type === type);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleAddCredit() {
|
|
||||||
const amount = parseFloat(creditAmount);
|
|
||||||
if (!amount || amount <= 0) { setCreditError("请输入有效金额"); return; }
|
|
||||||
setCreditLoading(true);
|
|
||||||
setCreditError("");
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/platform/workspaces/${id}/add-credit`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ amount, description: creditDesc }),
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
if (!res.ok) { setCreditError(data.error || "充值失败"); return; }
|
|
||||||
setShowCredit(false);
|
|
||||||
setCreditAmount("");
|
|
||||||
setCreditDesc("");
|
|
||||||
reload();
|
|
||||||
} catch {
|
|
||||||
setCreditError("充值失败,请重试");
|
|
||||||
} finally {
|
|
||||||
setCreditLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Search users for adding member
|
|
||||||
async function handleUserSearch(q: string) {
|
|
||||||
setAddUserId(q); // keep the raw typed text for form submission
|
|
||||||
if (q.length < 2) { setSearchUsers([]); return; }
|
|
||||||
setSearchLoading(true);
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/platform/users?search=${encodeURIComponent(q)}&pageSize=10`);
|
|
||||||
const data = await res.json();
|
|
||||||
if (!res.ok) {
|
|
||||||
// Show error from API
|
|
||||||
setSearchUsers([]);
|
|
||||||
setAddMemberError(data.error || "搜索失败,请重试");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const existingIds = new Set(members.map(m => m.uid));
|
|
||||||
setSearchUsers((data.users || []).filter((u: { uid: string }) => !existingIds.has(u.uid)));
|
|
||||||
setAddMemberError(""); // clear previous errors
|
|
||||||
} catch {
|
|
||||||
setSearchUsers([]);
|
|
||||||
setAddMemberError("搜索失败,请检查网络连接");
|
|
||||||
}
|
|
||||||
finally { setSearchLoading(false); }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleAddMember() {
|
|
||||||
if (!addUserId) { setAddMemberError("请选择用户"); return; }
|
|
||||||
setAddMemberLoading(true);
|
|
||||||
setAddMemberError("");
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/platform/workspaces/${id}/members`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ userId: addUserId, role: addRole }),
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
if (!res.ok) { setAddMemberError(data.error || "添加失败"); return; }
|
|
||||||
setShowAddMember(false);
|
|
||||||
setAddUserId("");
|
|
||||||
setAddUserDisplay("");
|
|
||||||
setAddRole("member");
|
|
||||||
setSearchUsers([]);
|
|
||||||
reload();
|
|
||||||
} catch { setAddMemberError("添加失败"); }
|
|
||||||
finally { setAddMemberLoading(false); }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleRemoveMember(memberId: number) {
|
|
||||||
if (!confirm("确定移除该成员?")) return;
|
|
||||||
try {
|
|
||||||
await fetch(`/api/platform/workspaces/${id}/members/${memberId}`, { method: "DELETE" });
|
|
||||||
reload();
|
|
||||||
} catch { window.alert("移除失败"); }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleUpdateRole(memberId: number, role: string) {
|
|
||||||
try {
|
|
||||||
await fetch(`/api/platform/workspaces/${id}/members/${memberId}`, {
|
|
||||||
method: "PATCH",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ role }),
|
|
||||||
});
|
|
||||||
reload();
|
|
||||||
} catch { window.alert("更新失败"); }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (loading) return <div className="admin-content"><div className="loading">加载中...</div></div>;
|
|
||||||
if (error || !workspace) return <div className="admin-content"><div className="alert alert-error">{error || "未找到"}</div></div>;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="admin-content">
|
|
||||||
<div className="page-header">
|
|
||||||
<h1 className="page-title">{workspace.name}</h1>
|
|
||||||
<p className="page-subtitle">
|
|
||||||
<code>{workspace.slug}</code>
|
|
||||||
<span className="badge badge-neutral" style={{ marginLeft: "8px" }}>{PLAN_LABELS[workspace.plan] || workspace.plan}</span>
|
|
||||||
{workspace.billing_email && <span style={{ marginLeft: "8px", fontSize: "13px", color: "#737373" }}>{workspace.billing_email}</span>}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Stats row */}
|
|
||||||
<div className="stats-grid">
|
|
||||||
<div className="stat-card">
|
|
||||||
<div className="stat-value">{members.length}</div>
|
|
||||||
<div className="stat-label">成员</div>
|
|
||||||
</div>
|
|
||||||
<div className="stat-card">
|
|
||||||
<div className="stat-value">{projects.length}</div>
|
|
||||||
<div className="stat-label">项目</div>
|
|
||||||
</div>
|
|
||||||
<div className="stat-card">
|
|
||||||
<div className="stat-value">
|
|
||||||
{workspace.balance ? `${parseFloat(workspace.balance).toFixed(2)}` : "0.00"} {workspace.currency || "USD"}
|
|
||||||
</div>
|
|
||||||
<div className="stat-label">
|
|
||||||
当前余额
|
|
||||||
<button className="btn btn-secondary btn-sm" style={{ marginLeft: "8px" }} onClick={() => setShowCredit(true)}>+ 充值</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="stat-card">
|
|
||||||
<div className="stat-value">
|
|
||||||
{workspace.total_spent ? `${parseFloat(workspace.total_spent).toFixed(2)}` : "0.00"}
|
|
||||||
</div>
|
|
||||||
<div className="stat-label">累计消费</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{workspace.description && (
|
|
||||||
<div className="card" style={{ marginBottom: "16px" }}>
|
|
||||||
<p style={{ margin: 0, color: "#737373", fontSize: "14px" }}>{workspace.description}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Tabs */}
|
|
||||||
<div className="toolbar" style={{ marginBottom: "0" }}>
|
|
||||||
<button className={`btn btn-sm ${tab === "members" ? "btn-primary" : "btn-secondary"}`} onClick={() => setTab("members")}>成员 ({members.length})</button>
|
|
||||||
<button className={`btn btn-sm ${tab === "projects" ? "btn-primary" : "btn-secondary"}`} onClick={() => setTab("projects")}>项目 ({projects.length})</button>
|
|
||||||
<button className={`btn btn-sm ${tab === "billing" ? "btn-primary" : "btn-secondary"}`} onClick={() => setTab("billing")}>账单历史</button>
|
|
||||||
<button className={`btn btn-sm ${tab === "alerts" ? "btn-primary" : "btn-secondary"}`} onClick={() => setTab("alerts")}>告警配置</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="card">
|
|
||||||
{tab === "members" && (
|
|
||||||
<>
|
|
||||||
<div style={{ padding: "12px", borderBottom: "1px solid #e5e5e5", display: "flex", justifyContent: "flex-end" }}>
|
|
||||||
<button className="btn btn-sm btn-primary" onClick={() => setShowAddMember(true)}>+ 添加成员</button>
|
|
||||||
</div>
|
|
||||||
<div className="table-container">
|
|
||||||
<table className="data-table">
|
|
||||||
<thead><tr><th>用户名</th><th>显示名</th><th>角色</th><th>状态</th><th>加入时间</th><th>操作</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
{members.map((m) => (
|
|
||||||
<tr key={m.id ?? m.uid}>
|
|
||||||
<td>{m.username}</td>
|
|
||||||
<td>{m.display_name || "-"}</td>
|
|
||||||
<td>
|
|
||||||
{m.role === "owner" ? (
|
|
||||||
<span className="badge badge-success">{ROLE_LABELS[m.role] || m.role}</span>
|
|
||||||
) : (
|
|
||||||
<select
|
|
||||||
className="form-input"
|
|
||||||
style={{ padding: "2px 6px", fontSize: "12px", width: "auto" }}
|
|
||||||
value={m.role}
|
|
||||||
onChange={(e) => m.id && handleUpdateRole(m.id, e.target.value)}
|
|
||||||
>
|
|
||||||
<option value="admin">管理员</option>
|
|
||||||
<option value="member">成员</option>
|
|
||||||
</select>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{m.status && m.status !== "active" ? (
|
|
||||||
<span className="badge badge-neutral">{m.status}</span>
|
|
||||||
) : (
|
|
||||||
<span className="badge badge-success">正常</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td>{format(new Date(m.joined_at), "yyyy-MM-dd")}</td>
|
|
||||||
<td>
|
|
||||||
{m.role !== "owner" && (
|
|
||||||
<button className="btn btn-danger btn-sm" onClick={() => m.id && handleRemoveMember(m.id)}>移除</button>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
{members.length === 0 && <tr><td colSpan={6} style={{ textAlign: "center", padding: "24px", color: "#737373" }}>暂无成员</td></tr>}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{tab === "projects" && (
|
|
||||||
<div className="table-container">
|
|
||||||
<table className="data-table">
|
|
||||||
<thead><tr><th>名称</th><th>可见性</th><th>成员</th><th>创建时间</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
{projects.map((p) => (
|
|
||||||
<tr key={p.id}>
|
|
||||||
<td>{p.name}</td>
|
|
||||||
<td><span className={`badge ${p.is_public ? "badge-success" : "badge-neutral"}`}>{p.is_public ? "公开" : "私有"}</span></td>
|
|
||||||
<td>{p.member_count}</td>
|
|
||||||
<td>{format(new Date(p.created_at), "yyyy-MM-dd")}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
{projects.length === 0 && <tr><td colSpan={4} style={{ textAlign: "center", padding: "24px", color: "#737373" }}>暂无项目</td></tr>}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{tab === "billing" && (
|
|
||||||
<div className="table-container">
|
|
||||||
<table className="data-table">
|
|
||||||
<thead><tr><th>时间</th><th>类型</th><th>金额</th><th>描述</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
{billingHistory.map((b, i) => (
|
|
||||||
<tr key={i}>
|
|
||||||
<td>{format(new Date(b.created_at), "yyyy-MM-dd HH:mm")}</td>
|
|
||||||
<td><span className="badge badge-neutral">{b.reason}</span></td>
|
|
||||||
<td style={{ color: parseFloat(b.amount) >= 0 ? "#16a34a" : "#dc2626" }}>
|
|
||||||
{parseFloat(b.amount) >= 0 ? "+" : ""}{parseFloat(b.amount).toFixed(4)}
|
|
||||||
</td>
|
|
||||||
<td style={{ fontSize: "12px", color: "#737373" }}>{b.description || "-"}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
{billingHistory.length === 0 && <tr><td colSpan={4} style={{ textAlign: "center", padding: "24px", color: "#737373" }}>暂无账单记录</td></tr>}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{tab === "alerts" && (
|
|
||||||
<div>
|
|
||||||
<p style={{ fontSize: "13px", color: "#737373", marginBottom: "16px" }}>
|
|
||||||
配置 Workspace 的告警规则。当触发条件满足时,将发送邮件通知 Workspace 管理员。
|
|
||||||
</p>
|
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: "16px" }}>
|
|
||||||
{Object.entries(ALERT_TYPE_LABELS).map(([type, meta]) => {
|
|
||||||
const cfg = getAlert(type);
|
|
||||||
const enabled = cfg?.enabled ?? false;
|
|
||||||
const threshold = cfg?.threshold ?? "";
|
|
||||||
const emailEnabled = cfg?.email_enabled ?? true;
|
|
||||||
return (
|
|
||||||
<div key={type} style={{ border: "1px solid #e5e5e5", borderRadius: "8px", padding: "16px" }}>
|
|
||||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: "12px" }}>
|
|
||||||
<div>
|
|
||||||
<div style={{ fontWeight: 500 }}>{meta.label}</div>
|
|
||||||
<div style={{ fontSize: "12px", color: "#737373", marginTop: "2px" }}>
|
|
||||||
当阈值低于 {meta.unit} 时触发
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<label style={{ display: "flex", alignItems: "center", gap: "8px", cursor: "pointer" }}>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={enabled}
|
|
||||||
onChange={(e) => updateAlert(type, "enabled", e.target.checked)}
|
|
||||||
/>
|
|
||||||
<span style={{ fontSize: "13px" }}>启用告警</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
{enabled && (
|
|
||||||
<div style={{ display: "flex", gap: "12px", alignItems: "center" }}>
|
|
||||||
<div style={{ flex: 1 }}>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
className="form-input"
|
|
||||||
placeholder={meta.placeholder}
|
|
||||||
value={threshold}
|
|
||||||
min="0"
|
|
||||||
step={meta.unit === "%" ? "1" : "0.01"}
|
|
||||||
onChange={(e) => updateAlert(type, "threshold", e.target.value)}
|
|
||||||
style={{ maxWidth: "200px" }}
|
|
||||||
/>
|
|
||||||
<span style={{ marginLeft: "8px", fontSize: "13px", color: "#737373" }}>{meta.unit}</span>
|
|
||||||
</div>
|
|
||||||
<label style={{ display: "flex", alignItems: "center", gap: "6px", cursor: "pointer", fontSize: "13px" }}>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={emailEnabled}
|
|
||||||
onChange={(e) => updateAlert(type, "email_enabled", e.target.checked)}
|
|
||||||
/>
|
|
||||||
邮件通知
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
<div style={{ marginTop: "16px" }}>
|
|
||||||
<button className="btn btn-primary" disabled={alertSaving} onClick={handleSaveAlerts}>
|
|
||||||
{alertSaving ? "保存中..." : "保存配置"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showCredit && (
|
|
||||||
<div className="modal-overlay" onClick={() => setShowCredit(false)}>
|
|
||||||
<div className="modal" onClick={(e) => e.stopPropagation()} style={{ maxWidth: "420px" }}>
|
|
||||||
<h2 className="modal-title">为 Workspace 充值</h2>
|
|
||||||
{creditError && <div className="alert alert-error">{creditError}</div>}
|
|
||||||
<div className="form-group">
|
|
||||||
<label className="form-label">充值金额(USD)</label>
|
|
||||||
<input type="number" className="form-input" min="0.01" step="0.01"
|
|
||||||
value={creditAmount} onChange={(e) => setCreditAmount(e.target.value)}
|
|
||||||
placeholder="例如:100.00" autoFocus />
|
|
||||||
</div>
|
|
||||||
<div className="form-group">
|
|
||||||
<label className="form-label">备注(可选)</label>
|
|
||||||
<input type="text" className="form-input"
|
|
||||||
value={creditDesc} onChange={(e) => setCreditDesc(e.target.value)}
|
|
||||||
placeholder="充值原因或备注" />
|
|
||||||
</div>
|
|
||||||
<div className="modal-footer">
|
|
||||||
<button className="btn btn-secondary" onClick={() => setShowCredit(false)}>取消</button>
|
|
||||||
<button className="btn btn-primary" disabled={creditLoading} onClick={handleAddCredit}>
|
|
||||||
{creditLoading ? "充值中..." : "确认充值"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showAddMember && (
|
|
||||||
<div className="modal-overlay" onClick={() => setShowAddMember(false)}>
|
|
||||||
<div className="modal" onClick={(e) => e.stopPropagation()} style={{ maxWidth: "440px" }}>
|
|
||||||
<h2 className="modal-title">添加成员</h2>
|
|
||||||
{addMemberError && <div className="alert alert-error">{addMemberError}</div>}
|
|
||||||
<div className="form-group">
|
|
||||||
<label className="form-label">搜索用户</label>
|
|
||||||
<input
|
|
||||||
className="form-input"
|
|
||||||
value={addUserDisplay || addUserId}
|
|
||||||
onChange={(e) => { setAddUserDisplay(e.target.value); handleUserSearch(e.target.value); }}
|
|
||||||
placeholder="输入用户名搜索..."
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
{searchUsers.length > 0 && (
|
|
||||||
<div style={{ border: "1px solid #e5e5e5", borderRadius: "6px", marginTop: "4px", maxHeight: "200px", overflowY: "auto", background: "#fff" }}>
|
|
||||||
{searchUsers.map((u) => (
|
|
||||||
<div
|
|
||||||
key={u.uid}
|
|
||||||
style={{ padding: "8px 12px", cursor: "pointer", borderBottom: "1px solid #f0f0f0" }}
|
|
||||||
onClick={() => {
|
|
||||||
setAddUserId(u.uid);
|
|
||||||
setAddUserDisplay(u.username);
|
|
||||||
setSearchUsers([]);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ fontWeight: 500, fontSize: "14px" }}>{u.username}</div>
|
|
||||||
<div style={{ fontSize: "12px", color: "#737373" }}>{u.display_name || ""}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{searchLoading && <div style={{ fontSize: "12px", color: "#737373", marginTop: "4px" }}>搜索中...</div>}
|
|
||||||
</div>
|
|
||||||
<div className="form-group">
|
|
||||||
<label className="form-label">角色</label>
|
|
||||||
<select className="form-input" value={addRole} onChange={(e) => setAddRole(e.target.value)}>
|
|
||||||
<option value="admin">管理员</option>
|
|
||||||
<option value="member">成员</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className="modal-footer">
|
|
||||||
<button className="btn btn-secondary" onClick={() => { setShowAddMember(false); setSearchUsers([]); setAddUserDisplay(""); }}>取消</button>
|
|
||||||
<button className="btn btn-primary" disabled={addMemberLoading || !addUserId} onClick={handleAddMember}>
|
|
||||||
{addMemberLoading ? "添加中..." : "添加"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,191 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { format } from "date-fns";
|
|
||||||
|
|
||||||
interface Workspace {
|
|
||||||
id: string;
|
|
||||||
slug: string;
|
|
||||||
name: string;
|
|
||||||
plan: string;
|
|
||||||
description: string | null;
|
|
||||||
billing_email: string | null;
|
|
||||||
balance: string | null;
|
|
||||||
currency: string | null;
|
|
||||||
member_count: number;
|
|
||||||
project_count: number;
|
|
||||||
created_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const PLAN_LABELS: Record<string, string> = {
|
|
||||||
free: "免费", starter: "入门", pro: "专业", enterprise: "企业",
|
|
||||||
};
|
|
||||||
|
|
||||||
const ALL_PLANS = ["free", "starter", "pro", "enterprise"];
|
|
||||||
|
|
||||||
export default function WorkspacesPage() {
|
|
||||||
const [workspaces, setWorkspaces] = useState<Workspace[]>([]);
|
|
||||||
const [total, setTotal] = useState(0);
|
|
||||||
const [page, setPage] = useState(1);
|
|
||||||
const [search, setSearch] = useState("");
|
|
||||||
const [plan, setPlan] = useState("");
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
|
||||||
const [batchPlan, setBatchPlan] = useState("");
|
|
||||||
const [batchLoading, setBatchLoading] = useState(false);
|
|
||||||
const pageSize = 20;
|
|
||||||
|
|
||||||
useEffect(() => { loadWorkspaces(); }, [page, search, plan]);
|
|
||||||
|
|
||||||
function loadWorkspaces() {
|
|
||||||
setLoading(true);
|
|
||||||
setSelected(new Set());
|
|
||||||
const params = new URLSearchParams({ page: String(page), pageSize: String(pageSize) });
|
|
||||||
if (search) params.set("search", search);
|
|
||||||
if (plan) params.set("plan", plan);
|
|
||||||
fetch(`/api/platform/workspaces?${params}`)
|
|
||||||
.then((r) => r.json())
|
|
||||||
.then((data) => {
|
|
||||||
setWorkspaces(data.workspaces || []);
|
|
||||||
setTotal(data.total || 0);
|
|
||||||
})
|
|
||||||
.catch(console.error)
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleAll() {
|
|
||||||
if (selected.size === workspaces.length) {
|
|
||||||
setSelected(new Set());
|
|
||||||
} else {
|
|
||||||
setSelected(new Set(workspaces.map((w) => w.id)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggle(id: string) {
|
|
||||||
const next = new Set(selected);
|
|
||||||
if (next.has(id)) next.delete(id);
|
|
||||||
else next.add(id);
|
|
||||||
setSelected(next);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleBatchPlanChange() {
|
|
||||||
if (!batchPlan || selected.size === 0) return;
|
|
||||||
if (!confirm(`将 ${selected.size} 个 Workspace 调整为「${PLAN_LABELS[batchPlan] || batchPlan}」?`)) return;
|
|
||||||
setBatchLoading(true);
|
|
||||||
try {
|
|
||||||
const res = await fetch("/api/platform/workspaces", {
|
|
||||||
method: "PATCH",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ ids: Array.from(selected), plan: batchPlan }),
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
if (res.ok) {
|
|
||||||
setSelected(new Set());
|
|
||||||
setBatchPlan("");
|
|
||||||
loadWorkspaces();
|
|
||||||
} else {
|
|
||||||
alert(data.error || "批量调整失败");
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
alert("批量调整失败");
|
|
||||||
} finally {
|
|
||||||
setBatchLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const totalPages = Math.ceil(total / pageSize);
|
|
||||||
const allSelected = workspaces.length > 0 && selected.size === workspaces.length;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="admin-content">
|
|
||||||
<div className="page-header">
|
|
||||||
<h1 className="page-title">Workspace 管理</h1>
|
|
||||||
<p className="page-subtitle">共 {total} 个 Workspace</p>
|
|
||||||
</div>
|
|
||||||
<div className="toolbar">
|
|
||||||
<input
|
|
||||||
type="text" className="form-input search-input"
|
|
||||||
placeholder="搜索名称或 slug..."
|
|
||||||
value={search} onChange={(e) => { setSearch(e.target.value); setPage(1); }}
|
|
||||||
/>
|
|
||||||
<select className="form-input" style={{ maxWidth: "160px" }}
|
|
||||||
value={plan} onChange={(e) => { setPlan(e.target.value); setPage(1); }}>
|
|
||||||
<option value="">全部计划</option>
|
|
||||||
{Object.entries(PLAN_LABELS).map(([k, v]) => (
|
|
||||||
<option key={k} value={k}>{v}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
{selected.size > 0 && (
|
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: "8px", marginLeft: "8px" }}>
|
|
||||||
<span style={{ fontSize: "13px", color: "#737373" }}>已选 {selected.size} 个</span>
|
|
||||||
<select className="form-input" style={{ maxWidth: "120px" }}
|
|
||||||
value={batchPlan} onChange={(e) => setBatchPlan(e.target.value)}>
|
|
||||||
<option value="">选择计划</option>
|
|
||||||
{ALL_PLANS.map((p) => (
|
|
||||||
<option key={p} value={p}>{PLAN_LABELS[p]}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<button className="btn btn-primary btn-sm" disabled={!batchPlan || batchLoading}
|
|
||||||
onClick={handleBatchPlanChange}>
|
|
||||||
{batchLoading ? "调整中..." : "批量调整计划"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="card">
|
|
||||||
{loading ? <div className="loading">加载中...</div> : (
|
|
||||||
<div className="table-container">
|
|
||||||
<table className="data-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th style={{ width: "36px" }}>
|
|
||||||
<input type="checkbox" checked={allSelected} onChange={toggleAll} />
|
|
||||||
</th>
|
|
||||||
<th>名称</th><th>Slug</th><th>计划</th>
|
|
||||||
<th>余额</th><th>成员</th><th>项目</th><th>创建时间</th>
|
|
||||||
<th>操作</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{workspaces.map((w) => (
|
|
||||||
<tr key={w.id}>
|
|
||||||
<td>
|
|
||||||
<input type="checkbox" checked={selected.has(w.id)} onChange={() => toggle(w.id)} />
|
|
||||||
</td>
|
|
||||||
<td>{w.name}</td>
|
|
||||||
<td><code style={{ fontSize: "12px" }}>{w.slug}</code></td>
|
|
||||||
<td><span className="badge badge-neutral">{PLAN_LABELS[w.plan] || w.plan}</span></td>
|
|
||||||
<td>
|
|
||||||
{w.balance
|
|
||||||
? `${parseFloat(w.balance).toFixed(2)} ${w.currency || "USD"}`
|
|
||||||
: "-"}
|
|
||||||
</td>
|
|
||||||
<td>{w.member_count}</td>
|
|
||||||
<td>{w.project_count}</td>
|
|
||||||
<td>{format(new Date(w.created_at), "yyyy-MM-dd")}</td>
|
|
||||||
<td>
|
|
||||||
<Link href={`/admin/workspaces/${w.id}`}>
|
|
||||||
<button className="btn btn-secondary btn-sm">详情</button>
|
|
||||||
</Link>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
{workspaces.length === 0 && (
|
|
||||||
<tr><td colSpan={9} style={{ textAlign: "center", padding: "24px", color: "#737373" }}>暂无数据</td></tr>
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{totalPages > 1 && (
|
|
||||||
<div className="pagination">
|
|
||||||
<span className="pagination-info">第 {page} / {totalPages} 页,共 {total} 条</span>
|
|
||||||
<button className="btn btn-secondary btn-sm" disabled={page <= 1} onClick={() => setPage(p => p - 1)}>上一页</button>
|
|
||||||
<button className="btn btn-secondary btn-sm" disabled={page >= totalPages} onClick={() => setPage(p => p + 1)}>下一页</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,49 +0,0 @@
|
|||||||
import { logError } from "@/lib/logger";
|
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
|
||||||
import { createModel, updateModel, deleteModel } from "@/lib/adminrpc/client";
|
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
|
||||||
|
|
||||||
// POST /api/admin/ai/models — create model
|
|
||||||
export async function POST(req: NextRequest) {
|
|
||||||
try {
|
|
||||||
const body = await req.json();
|
|
||||||
const data = await createModel(body);
|
|
||||||
return NextResponse.json(data);
|
|
||||||
} catch (e) {
|
|
||||||
const msg = e instanceof Error ? e.message : String(e);
|
|
||||||
logError("Create model error:", e);
|
|
||||||
return NextResponse.json({ error: `创建失败: ${msg}` }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// PATCH /api/admin/ai/models?id={id} — update model
|
|
||||||
export async function PATCH(req: NextRequest) {
|
|
||||||
try {
|
|
||||||
const { searchParams } = new URL(req.url);
|
|
||||||
const id = searchParams.get("id");
|
|
||||||
if (!id) return NextResponse.json({ error: "Missing id" }, { status: 400 });
|
|
||||||
const body = await req.json();
|
|
||||||
const data = await updateModel(id, body);
|
|
||||||
return NextResponse.json(data);
|
|
||||||
} catch (e) {
|
|
||||||
const msg = e instanceof Error ? e.message : String(e);
|
|
||||||
logError("Update model error:", e);
|
|
||||||
return NextResponse.json({ error: `更新失败: ${msg}` }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// DELETE /api/admin/ai/models?id={id} — delete model
|
|
||||||
export async function DELETE(req: NextRequest) {
|
|
||||||
try {
|
|
||||||
const { searchParams } = new URL(req.url);
|
|
||||||
const id = searchParams.get("id");
|
|
||||||
if (!id) return NextResponse.json({ error: "Missing id" }, { status: 400 });
|
|
||||||
const data = await deleteModel(id);
|
|
||||||
return NextResponse.json(data);
|
|
||||||
} catch (e) {
|
|
||||||
const msg = e instanceof Error ? e.message : String(e);
|
|
||||||
logError("Delete model error:", e);
|
|
||||||
return NextResponse.json({ error: `删除失败: ${msg}` }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,22 +0,0 @@
|
|||||||
import { logError } from "@/lib/logger";
|
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
|
||||||
import { updatePricing } from "@/lib/adminrpc/client";
|
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
|
||||||
|
|
||||||
// PATCH /api/admin/ai/pricing/{id} — update pricing
|
|
||||||
export async function PATCH(
|
|
||||||
req: NextRequest,
|
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const { id } = await params;
|
|
||||||
const body = await req.json();
|
|
||||||
const data = await updatePricing(id, body);
|
|
||||||
return NextResponse.json(data);
|
|
||||||
} catch (e) {
|
|
||||||
const msg = e instanceof Error ? e.message : String(e);
|
|
||||||
logError("Update pricing error:", e);
|
|
||||||
return NextResponse.json({ error: `更新失败: ${msg}` }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,49 +0,0 @@
|
|||||||
import { logError } from "@/lib/logger";
|
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
|
||||||
import { createProvider, updateProvider, deleteProvider } from "@/lib/adminrpc/client";
|
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
|
||||||
|
|
||||||
// POST /api/admin/ai/providers — create provider
|
|
||||||
export async function POST(req: NextRequest) {
|
|
||||||
try {
|
|
||||||
const body = await req.json();
|
|
||||||
const data = await createProvider(body);
|
|
||||||
return NextResponse.json(data);
|
|
||||||
} catch (e) {
|
|
||||||
const msg = e instanceof Error ? e.message : String(e);
|
|
||||||
logError("Create provider error:", e);
|
|
||||||
return NextResponse.json({ error: `创建失败: ${msg}` }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// PATCH /api/admin/ai/providers?id={id} — update provider
|
|
||||||
export async function PATCH(req: NextRequest) {
|
|
||||||
try {
|
|
||||||
const { searchParams } = new URL(req.url);
|
|
||||||
const id = searchParams.get("id");
|
|
||||||
if (!id) return NextResponse.json({ error: "Missing id" }, { status: 400 });
|
|
||||||
const body = await req.json();
|
|
||||||
const data = await updateProvider(id, body);
|
|
||||||
return NextResponse.json(data);
|
|
||||||
} catch (e) {
|
|
||||||
const msg = e instanceof Error ? e.message : String(e);
|
|
||||||
logError("Update provider error:", e);
|
|
||||||
return NextResponse.json({ error: `更新失败: ${msg}` }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// DELETE /api/admin/ai/providers?id={id} — delete provider
|
|
||||||
export async function DELETE(req: NextRequest) {
|
|
||||||
try {
|
|
||||||
const { searchParams } = new URL(req.url);
|
|
||||||
const id = searchParams.get("id");
|
|
||||||
if (!id) return NextResponse.json({ error: "Missing id" }, { status: 400 });
|
|
||||||
const data = await deleteProvider(id);
|
|
||||||
return NextResponse.json(data);
|
|
||||||
} catch (e) {
|
|
||||||
const msg = e instanceof Error ? e.message : String(e);
|
|
||||||
logError("Delete provider error:", e);
|
|
||||||
return NextResponse.json({ error: `删除失败: ${msg}` }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,49 +0,0 @@
|
|||||||
import { logError } from "@/lib/logger";
|
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
|
||||||
import { createVersion, updateVersion, deleteVersion } from "@/lib/adminrpc/client";
|
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
|
||||||
|
|
||||||
// POST /api/admin/ai/versions — create version
|
|
||||||
export async function POST(req: NextRequest) {
|
|
||||||
try {
|
|
||||||
const body = await req.json();
|
|
||||||
const data = await createVersion(body);
|
|
||||||
return NextResponse.json(data);
|
|
||||||
} catch (e) {
|
|
||||||
const msg = e instanceof Error ? e.message : String(e);
|
|
||||||
logError("Create version error:", e);
|
|
||||||
return NextResponse.json({ error: `创建失败: ${msg}` }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// PATCH /api/admin/ai/versions?id={id} — update version
|
|
||||||
export async function PATCH(req: NextRequest) {
|
|
||||||
try {
|
|
||||||
const { searchParams } = new URL(req.url);
|
|
||||||
const id = searchParams.get("id");
|
|
||||||
if (!id) return NextResponse.json({ error: "Missing id" }, { status: 400 });
|
|
||||||
const body = await req.json();
|
|
||||||
const data = await updateVersion(id, body);
|
|
||||||
return NextResponse.json(data);
|
|
||||||
} catch (e) {
|
|
||||||
const msg = e instanceof Error ? e.message : String(e);
|
|
||||||
logError("Update version error:", e);
|
|
||||||
return NextResponse.json({ error: `更新失败: ${msg}` }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// DELETE /api/admin/ai/versions?id={id} — delete version
|
|
||||||
export async function DELETE(req: NextRequest) {
|
|
||||||
try {
|
|
||||||
const { searchParams } = new URL(req.url);
|
|
||||||
const id = searchParams.get("id");
|
|
||||||
if (!id) return NextResponse.json({ error: "Missing id" }, { status: 400 });
|
|
||||||
const data = await deleteVersion(id);
|
|
||||||
return NextResponse.json(data);
|
|
||||||
} catch (e) {
|
|
||||||
const msg = e instanceof Error ? e.message : String(e);
|
|
||||||
logError("Delete version error:", e);
|
|
||||||
return NextResponse.json({ error: `删除失败: ${msg}` }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,89 +0,0 @@
|
|||||||
import { logError } from "@/lib/logger";
|
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
|
||||||
import { query, transaction } from "@/lib/db";
|
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
|
||||||
|
|
||||||
// GET /api/admin/projects/[id]/billing — get project billing info
|
|
||||||
export async function GET(
|
|
||||||
req: NextRequest,
|
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
|
||||||
) {
|
|
||||||
const { id } = await params;
|
|
||||||
try {
|
|
||||||
const billingRes = await query(
|
|
||||||
`SELECT pb.project_uuid, pb.balance, pb.currency, pb.updated_at, pb.created_at
|
|
||||||
FROM project_billing pb WHERE pb.project_uuid = $1`,
|
|
||||||
[id]
|
|
||||||
);
|
|
||||||
const historyRes = await query(
|
|
||||||
`SELECT uid, project, user, amount, currency, reason, extra, created_at
|
|
||||||
FROM project_billing_history
|
|
||||||
WHERE project = $1
|
|
||||||
ORDER BY created_at DESC
|
|
||||||
LIMIT 100`,
|
|
||||||
[id]
|
|
||||||
);
|
|
||||||
const billing = billingRes.rows[0];
|
|
||||||
return NextResponse.json({
|
|
||||||
billing: billing ? {
|
|
||||||
balance: billing.balance,
|
|
||||||
currency: billing.currency,
|
|
||||||
updated_at: billing.updated_at,
|
|
||||||
created_at: billing.created_at,
|
|
||||||
} : null,
|
|
||||||
history: historyRes.rows.map((r: Record<string, unknown>) => ({
|
|
||||||
uid: String(r.uid),
|
|
||||||
amount: String(r.amount),
|
|
||||||
currency: r.currency,
|
|
||||||
reason: r.reason,
|
|
||||||
description: r.extra ? (r.extra as Record<string, unknown>).description : null,
|
|
||||||
created_at: r.created_at,
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
logError("Project billing error:", e);
|
|
||||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// POST /api/admin/projects/[id]/billing — add credit
|
|
||||||
export async function POST(
|
|
||||||
req: NextRequest,
|
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
|
||||||
) {
|
|
||||||
const { id } = await params;
|
|
||||||
const body = await req.json();
|
|
||||||
const amount = parseFloat(body.amount);
|
|
||||||
const description = String(body.description || "Admin 充值");
|
|
||||||
|
|
||||||
if (!amount || amount <= 0) {
|
|
||||||
return NextResponse.json({ error: "金额必须大于 0" }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await transaction(async (tx) => {
|
|
||||||
// Upsert project_billing
|
|
||||||
await tx.query(
|
|
||||||
`INSERT INTO project_billing (project_uuid, balance, currency, updated_at, created_at)
|
|
||||||
VALUES ($1, $2, 'USD', NOW(), NOW())
|
|
||||||
ON CONFLICT (project_uuid) DO UPDATE SET
|
|
||||||
balance = project_billing.balance + $2,
|
|
||||||
updated_at = NOW()`,
|
|
||||||
[id, amount]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Insert history
|
|
||||||
await tx.query(
|
|
||||||
`INSERT INTO project_billing_history (uid, project, user, amount, currency, reason, extra, created_at)
|
|
||||||
VALUES (gen_random_uuid(), $1, NULL, $2, 'USD', 'admin_credit', ('{"description":"' || $3 || '"}')::jsonb, NOW())`,
|
|
||||||
[id, amount, description]
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
return NextResponse.json({ ok: true, amount });
|
|
||||||
} catch (e) {
|
|
||||||
logError("Project add credit error:", e);
|
|
||||||
return NextResponse.json({ error: "充值失败" }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,97 +0,0 @@
|
|||||||
import { logError } from "@/lib/logger";
|
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
|
||||||
import { query } from "@/lib/db";
|
|
||||||
import { createAuditLog } from "@/lib/log";
|
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
|
||||||
|
|
||||||
// PATCH /api/admin/projects/[id]/members/[memberId]
|
|
||||||
export async function PATCH(
|
|
||||||
req: NextRequest,
|
|
||||||
{ params }: { params: Promise<{ id: string; memberId: string }> }
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const { id, memberId } = await params;
|
|
||||||
const body = await req.json() as { scope?: string };
|
|
||||||
|
|
||||||
const adminUserId = parseInt(req.headers.get("x-admin-user-id") || "0", 10);
|
|
||||||
const adminUsername = req.headers.get("x-admin-username") || "unknown";
|
|
||||||
|
|
||||||
if (body.scope && !["owner", "admin", "member"].includes(body.scope)) {
|
|
||||||
return NextResponse.json({ error: "无效的角色" }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const member = await query<{ id: string; scope: string; user_uuid: string }>(
|
|
||||||
`SELECT id, scope, user_uuid FROM project_members WHERE id = $1 AND project_uuid = $2`,
|
|
||||||
[memberId, id]
|
|
||||||
);
|
|
||||||
if (!member.rows.length) {
|
|
||||||
return NextResponse.json({ error: "成员不存在" }, { status: 404 });
|
|
||||||
}
|
|
||||||
if (member.rows[0].scope === "owner") {
|
|
||||||
return NextResponse.json({ error: "无法修改 Owner 的角色" }, { status: 403 });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (body.scope) {
|
|
||||||
await query(`UPDATE project_members SET scope = $1 WHERE id = $2`, [body.scope, memberId]);
|
|
||||||
}
|
|
||||||
|
|
||||||
await createAuditLog({
|
|
||||||
userId: adminUserId,
|
|
||||||
username: adminUsername,
|
|
||||||
action: "update",
|
|
||||||
resource: "project_member",
|
|
||||||
resourceId: memberId,
|
|
||||||
requestParams: { projectId: id, scope: body.scope },
|
|
||||||
ipAddress: req.headers.get("x-forwarded-for") || undefined,
|
|
||||||
userAgent: req.headers.get("user-agent") || undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
return NextResponse.json({ success: true });
|
|
||||||
} catch (e) {
|
|
||||||
logError("Update project member error:", e);
|
|
||||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// DELETE /api/admin/projects/[id]/members/[memberId]
|
|
||||||
export async function DELETE(
|
|
||||||
req: NextRequest,
|
|
||||||
{ params }: { params: Promise<{ id: string; memberId: string }> }
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const { id, memberId } = await params;
|
|
||||||
|
|
||||||
const adminUserId = parseInt(req.headers.get("x-admin-user-id") || "0", 10);
|
|
||||||
const adminUsername = req.headers.get("x-admin-username") || "unknown";
|
|
||||||
|
|
||||||
const member = await query<{ id: string; scope: string; user_uuid: string }>(
|
|
||||||
`SELECT id, scope, user_uuid FROM project_members WHERE id = $1 AND project_uuid = $2`,
|
|
||||||
[memberId, id]
|
|
||||||
);
|
|
||||||
if (!member.rows.length) {
|
|
||||||
return NextResponse.json({ error: "成员不存在" }, { status: 404 });
|
|
||||||
}
|
|
||||||
if (member.rows[0].scope === "owner") {
|
|
||||||
return NextResponse.json({ error: "无法删除 Owner" }, { status: 403 });
|
|
||||||
}
|
|
||||||
|
|
||||||
await query(`DELETE FROM project_members WHERE id = $1`, [memberId]);
|
|
||||||
|
|
||||||
await createAuditLog({
|
|
||||||
userId: adminUserId,
|
|
||||||
username: adminUsername,
|
|
||||||
action: "delete",
|
|
||||||
resource: "project_member",
|
|
||||||
resourceId: memberId,
|
|
||||||
requestParams: { projectId: id, userId: member.rows[0].user_uuid },
|
|
||||||
ipAddress: req.headers.get("x-forwarded-for") || undefined,
|
|
||||||
userAgent: req.headers.get("user-agent") || undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
return NextResponse.json({ success: true });
|
|
||||||
} catch (e) {
|
|
||||||
logError("Delete project member error:", e);
|
|
||||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,115 +0,0 @@
|
|||||||
import { logError } from "@/lib/logger";
|
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
|
||||||
import { query } from "@/lib/db";
|
|
||||||
import { createAuditLog } from "@/lib/log";
|
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
|
||||||
|
|
||||||
// GET /api/admin/projects/[id]/members
|
|
||||||
export async function GET(
|
|
||||||
req: NextRequest,
|
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const { id } = await params;
|
|
||||||
|
|
||||||
const result = await query(
|
|
||||||
`SELECT pm.id, pm.project_uuid, pm.user_uuid, pm.scope, pm.joined_at::text,
|
|
||||||
u.username, u.display_name, u.avatar_url,
|
|
||||||
COALESCE(up.is_active, true) as user_is_active
|
|
||||||
FROM project_members pm
|
|
||||||
JOIN "user" u ON u.uid = pm.user_uuid
|
|
||||||
LEFT JOIN user_password up ON up.user = u.uid
|
|
||||||
WHERE pm.project_uuid = $1
|
|
||||||
ORDER BY pm.scope = 'owner' DESC, pm.scope = 'admin' DESC, pm.joined_at ASC`,
|
|
||||||
[id]
|
|
||||||
);
|
|
||||||
|
|
||||||
const members = result.rows.map((r: Record<string, unknown>) => ({
|
|
||||||
id: r.id,
|
|
||||||
projectId: r.project_uuid,
|
|
||||||
userId: r.user_uuid,
|
|
||||||
scope: r.scope,
|
|
||||||
joinedAt: r.joined_at,
|
|
||||||
username: r.username,
|
|
||||||
displayName: r.display_name,
|
|
||||||
avatarUrl: r.avatar_url,
|
|
||||||
userIsActive: r.user_is_active,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return NextResponse.json({ members });
|
|
||||||
} catch (e) {
|
|
||||||
logError("List project members error:", e);
|
|
||||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// POST /api/admin/projects/[id]/members
|
|
||||||
export async function POST(
|
|
||||||
req: NextRequest,
|
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const { id } = await params;
|
|
||||||
const body = await req.json() as {
|
|
||||||
userId?: string;
|
|
||||||
scope?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!body.userId) {
|
|
||||||
return NextResponse.json({ error: "缺少 userId" }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const adminUserId = parseInt(req.headers.get("x-admin-user-id") || "0", 10);
|
|
||||||
const adminUsername = req.headers.get("x-admin-username") || "unknown";
|
|
||||||
|
|
||||||
const scope = body.scope || "member";
|
|
||||||
if (!["owner", "admin", "member"].includes(scope)) {
|
|
||||||
return NextResponse.json({ error: "无效的角色" }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查 project 是否存在
|
|
||||||
const projRow = await query<{ id: string }>(`SELECT id FROM project WHERE id = $1`, [id]);
|
|
||||||
if (!projRow.rows.length) {
|
|
||||||
return NextResponse.json({ error: "项目不存在" }, { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查用户是否存在
|
|
||||||
const userRow = await query<{ uid: string }>(`SELECT uid FROM "user" WHERE uid = $1`, [body.userId]);
|
|
||||||
if (!userRow.rows.length) {
|
|
||||||
return NextResponse.json({ error: "用户不存在" }, { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查是否已是成员
|
|
||||||
const exist = await query(
|
|
||||||
`SELECT id FROM project_members WHERE project_uuid = $1 AND user_uuid = $2`,
|
|
||||||
[id, body.userId]
|
|
||||||
);
|
|
||||||
if (exist.rows.length) {
|
|
||||||
return NextResponse.json({ error: "该用户已是成员" }, { status: 409 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await query(
|
|
||||||
`INSERT INTO project_members (project_uuid, user_uuid, scope, joined_at)
|
|
||||||
VALUES ($1, $2, $3, NOW())
|
|
||||||
RETURNING id`,
|
|
||||||
[id, body.userId, scope]
|
|
||||||
);
|
|
||||||
|
|
||||||
await createAuditLog({
|
|
||||||
userId: adminUserId,
|
|
||||||
username: adminUsername,
|
|
||||||
action: "create",
|
|
||||||
resource: "project_member",
|
|
||||||
resourceId: String(result.rows[0]?.id),
|
|
||||||
requestParams: { projectId: id, userId: body.userId, scope },
|
|
||||||
ipAddress: req.headers.get("x-forwarded-for") || undefined,
|
|
||||||
userAgent: req.headers.get("user-agent") || undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
return NextResponse.json({ success: true, id: result.rows[0]?.id }, { status: 201 });
|
|
||||||
} catch (e) {
|
|
||||||
logError("Add project member error:", e);
|
|
||||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,63 +0,0 @@
|
|||||||
import { logError } from "@/lib/logger";
|
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
|
||||||
import { query } from "@/lib/db";
|
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
|
||||||
|
|
||||||
export async function GET(
|
|
||||||
_req: NextRequest,
|
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const { id } = await params;
|
|
||||||
|
|
||||||
const [projectRows, membersRows, billingRows, billingHistoryRows] = await Promise.all([
|
|
||||||
query(
|
|
||||||
`SELECT p.*, w.name as workspace_name, w.slug as workspace_slug, w.plan as workspace_plan,
|
|
||||||
COALESCE(pb.balance, 0)::text as balance, COALESCE(pb.currency, 'USD') as currency
|
|
||||||
FROM project p
|
|
||||||
LEFT JOIN workspace w ON w.id = p.workspace_id
|
|
||||||
LEFT JOIN project_billing pb ON pb.project_uuid = p.id
|
|
||||||
WHERE p.id = $1`,
|
|
||||||
[id]
|
|
||||||
),
|
|
||||||
query(
|
|
||||||
`SELECT pm.id, u.uid, u.username, u.display_name, u.avatar_url,
|
|
||||||
pm.scope as role, pm.joined_at
|
|
||||||
FROM project_members pm
|
|
||||||
JOIN "user" u ON u.uid = pm.user_uuid
|
|
||||||
WHERE pm.project_uuid = $1
|
|
||||||
ORDER BY pm.joined_at DESC`,
|
|
||||||
[id]
|
|
||||||
),
|
|
||||||
query(
|
|
||||||
`SELECT pb.balance::text as balance, pb.currency, pb.updated_at
|
|
||||||
FROM project_billing pb
|
|
||||||
WHERE pb.project_uuid = $1`,
|
|
||||||
[id]
|
|
||||||
),
|
|
||||||
query(
|
|
||||||
`SELECT reason, amount, extra as description, created_at
|
|
||||||
FROM project_billing_history
|
|
||||||
WHERE project = $1
|
|
||||||
ORDER BY created_at DESC
|
|
||||||
LIMIT 50`,
|
|
||||||
[id]
|
|
||||||
),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (!projectRows.rows.length) {
|
|
||||||
return NextResponse.json({ error: "项目不存在" }, { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
project: projectRows.rows[0],
|
|
||||||
members: membersRows.rows,
|
|
||||||
billing: billingRows.rows[0] || null,
|
|
||||||
billingHistory: billingHistoryRows.rows,
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
logError("Project detail error:", e);
|
|
||||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,71 +0,0 @@
|
|||||||
import { logError } from "@/lib/logger";
|
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
|
||||||
import { query } from "@/lib/db";
|
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
|
||||||
try {
|
|
||||||
const { searchParams } = req.nextUrl;
|
|
||||||
const page = Math.max(1, parseInt(searchParams.get("page") || "1", 10));
|
|
||||||
const pageSize = Math.max(1, parseInt(searchParams.get("pageSize") || "20", 10));
|
|
||||||
const search = searchParams.get("search") || "";
|
|
||||||
const workspaceId = searchParams.get("workspaceId") || "";
|
|
||||||
const visibility = searchParams.get("visibility") || "";
|
|
||||||
|
|
||||||
const offset = (page - 1) * pageSize;
|
|
||||||
|
|
||||||
const params: (string | number)[] = [];
|
|
||||||
let paramIdx = 1;
|
|
||||||
|
|
||||||
let where = "WHERE 1=1";
|
|
||||||
if (search) {
|
|
||||||
where += ` AND (p.name ILIKE $${paramIdx} OR p.display_name ILIKE $${paramIdx})`;
|
|
||||||
params.push(`%${search}%`);
|
|
||||||
paramIdx++;
|
|
||||||
}
|
|
||||||
if (workspaceId) {
|
|
||||||
where += ` AND p.workspace_id = $${paramIdx}`;
|
|
||||||
params.push(workspaceId);
|
|
||||||
paramIdx++;
|
|
||||||
}
|
|
||||||
if (visibility) {
|
|
||||||
where += ` AND p.is_public = $${paramIdx}`;
|
|
||||||
params.push(visibility === "public" ? 1 : 0);
|
|
||||||
paramIdx++;
|
|
||||||
}
|
|
||||||
|
|
||||||
const limitParam = paramIdx;
|
|
||||||
const offsetParam = paramIdx + 1;
|
|
||||||
|
|
||||||
const countResult = await query(
|
|
||||||
`SELECT COUNT(*) as total FROM project p ${where}`,
|
|
||||||
params
|
|
||||||
);
|
|
||||||
const total = parseInt(String(countResult.rows[0].total), 10);
|
|
||||||
|
|
||||||
params.push(pageSize, offset);
|
|
||||||
|
|
||||||
const projects = await query(
|
|
||||||
`SELECT p.id, p.name, p.display_name, p.description, p.is_public, p.workspace_id,
|
|
||||||
p.created_by, p.created_at, p.updated_at,
|
|
||||||
w.name as workspace_name, w.slug as workspace_slug,
|
|
||||||
COALESCE(b.balance, 0)::text as balance, COALESCE(b.currency, 'USD') as currency,
|
|
||||||
COUNT(DISTINCT pm.user_uuid) as member_count
|
|
||||||
FROM project p
|
|
||||||
LEFT JOIN workspace w ON w.id = p.workspace_id
|
|
||||||
LEFT JOIN project_billing b ON b.project_uuid = p.id
|
|
||||||
LEFT JOIN project_members pm ON pm.project_uuid = p.id
|
|
||||||
${where}
|
|
||||||
GROUP BY p.id, w.name, w.slug, b.balance, b.currency
|
|
||||||
ORDER BY p.created_at DESC
|
|
||||||
LIMIT $${limitParam} OFFSET $${offsetParam}`,
|
|
||||||
params
|
|
||||||
);
|
|
||||||
|
|
||||||
return NextResponse.json({ projects: projects.rows, total, page, pageSize });
|
|
||||||
} catch (e) {
|
|
||||||
logError("List projects error:", e);
|
|
||||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,115 +0,0 @@
|
|||||||
import { logError } from "@/lib/logger";
|
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
|
||||||
import { query } from "@/lib/db";
|
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
|
||||||
|
|
||||||
export async function GET(
|
|
||||||
req: NextRequest,
|
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const { id } = await params;
|
|
||||||
|
|
||||||
const [repoResult, branchesResult, commitsResult] = await Promise.all([
|
|
||||||
query<{
|
|
||||||
id: string;
|
|
||||||
repo_name: string;
|
|
||||||
project_id: string;
|
|
||||||
project_name: string;
|
|
||||||
workspace_name: string;
|
|
||||||
default_branch: string;
|
|
||||||
is_private: boolean;
|
|
||||||
description: string | null;
|
|
||||||
created_by: string;
|
|
||||||
created_at: Date;
|
|
||||||
ai_code_review_enabled: boolean;
|
|
||||||
collaborator_count: string;
|
|
||||||
}>(
|
|
||||||
`SELECT r.id, r.repo_name, r.project as project_id, p.name as project_name,
|
|
||||||
COALESCE(w.name, '') as workspace_name, r.default_branch, r.is_private,
|
|
||||||
r.description, r.created_by::text as created_by,
|
|
||||||
r.created_at::text as created_at, r.ai_code_review_enabled,
|
|
||||||
(SELECT COUNT(*) FROM repo_collaborator WHERE repo = r.id)::text as collaborator_count
|
|
||||||
FROM repo r
|
|
||||||
JOIN project p ON p.id = r.project
|
|
||||||
LEFT JOIN workspace w ON w.id = p.workspace_id
|
|
||||||
WHERE r.id = $1`,
|
|
||||||
[id]
|
|
||||||
),
|
|
||||||
query<{
|
|
||||||
name: string;
|
|
||||||
oid: string;
|
|
||||||
head: boolean;
|
|
||||||
upstream: string | null;
|
|
||||||
created_at: Date;
|
|
||||||
}>(
|
|
||||||
`SELECT name, oid, head, upstream, created_at::text as created_at
|
|
||||||
FROM repo_branch
|
|
||||||
WHERE repo = $1
|
|
||||||
ORDER BY head DESC, name ASC`,
|
|
||||||
[id]
|
|
||||||
),
|
|
||||||
query<{
|
|
||||||
id: number;
|
|
||||||
oid: string;
|
|
||||||
author_name: string;
|
|
||||||
author_email: string;
|
|
||||||
message: string;
|
|
||||||
created_at: Date;
|
|
||||||
}>(
|
|
||||||
`SELECT id, oid, author_name, author_email, message,
|
|
||||||
created_at::text as created_at
|
|
||||||
FROM repo_commit
|
|
||||||
WHERE repo = $1
|
|
||||||
ORDER BY created_at DESC
|
|
||||||
LIMIT 50`,
|
|
||||||
[id]
|
|
||||||
),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (!repoResult.rows.length) {
|
|
||||||
return NextResponse.json({ error: "仓库不存在" }, { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const r = repoResult.rows[0];
|
|
||||||
const repo = {
|
|
||||||
id: r.id,
|
|
||||||
repoName: r.repo_name,
|
|
||||||
projectId: r.project_id,
|
|
||||||
projectName: r.project_name,
|
|
||||||
workspaceName: r.workspace_name,
|
|
||||||
defaultBranch: r.default_branch,
|
|
||||||
isPrivate: r.is_private,
|
|
||||||
description: r.description,
|
|
||||||
createdBy: r.created_by,
|
|
||||||
createdAt: String(r.created_at),
|
|
||||||
aiCodeReviewEnabled: r.ai_code_review_enabled,
|
|
||||||
collaboratorCount: parseInt(r.collaborator_count || "0", 10),
|
|
||||||
};
|
|
||||||
|
|
||||||
const branches = branchesResult.rows.map((b) => ({
|
|
||||||
name: b.name,
|
|
||||||
oid: b.oid,
|
|
||||||
isHead: b.head,
|
|
||||||
upstream: b.upstream,
|
|
||||||
createdAt: String(b.created_at),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const commits = commitsResult.rows.map((c) => ({
|
|
||||||
id: c.id,
|
|
||||||
oid: c.oid,
|
|
||||||
shortOid: c.oid.slice(0, 7),
|
|
||||||
authorName: c.author_name,
|
|
||||||
authorEmail: c.author_email,
|
|
||||||
message: c.message,
|
|
||||||
shortMessage: c.message.split("\n")[0].slice(0, 80),
|
|
||||||
createdAt: String(c.created_at),
|
|
||||||
}));
|
|
||||||
|
|
||||||
return NextResponse.json({ repo, branches, commits });
|
|
||||||
} catch (e) {
|
|
||||||
logError("Repo detail error:", e);
|
|
||||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,36 +0,0 @@
|
|||||||
import { logError } from "@/lib/logger";
|
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
|
||||||
import { deleteApiToken } from "@/lib/api-token";
|
|
||||||
import { createAuditLog } from "@/lib/log";
|
|
||||||
|
|
||||||
export async function DELETE(
|
|
||||||
req: NextRequest,
|
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const { id } = await params;
|
|
||||||
const tokenId = parseInt(id, 10);
|
|
||||||
if (isNaN(tokenId)) {
|
|
||||||
return NextResponse.json({ error: "无效的 Token ID" }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
await deleteApiToken(tokenId);
|
|
||||||
|
|
||||||
const adminUserId = parseInt(req.headers.get("x-admin-user-id") || "0", 10);
|
|
||||||
const adminUsername = req.headers.get("x-admin-username") || "unknown";
|
|
||||||
await createAuditLog({
|
|
||||||
userId: adminUserId,
|
|
||||||
username: adminUsername,
|
|
||||||
action: "delete",
|
|
||||||
resource: "admin_api_token",
|
|
||||||
resourceId: id,
|
|
||||||
ipAddress: req.headers.get("x-forwarded-for") || undefined,
|
|
||||||
userAgent: req.headers.get("user-agent") || undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
return NextResponse.json({ success: true });
|
|
||||||
} catch (e) {
|
|
||||||
logError("Delete token error:", e);
|
|
||||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,65 +0,0 @@
|
|||||||
import { logError } from "@/lib/logger";
|
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
|
||||||
import {
|
|
||||||
listApiTokens,
|
|
||||||
createApiToken,
|
|
||||||
generateToken,
|
|
||||||
} from "@/lib/api-token";
|
|
||||||
import { createAuditLog } from "@/lib/log";
|
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
|
||||||
try {
|
|
||||||
const tokens = await listApiTokens();
|
|
||||||
return NextResponse.json({ tokens });
|
|
||||||
} catch (e) {
|
|
||||||
logError("List tokens error:", e);
|
|
||||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
|
||||||
try {
|
|
||||||
const body = await req.json() as {
|
|
||||||
name?: string;
|
|
||||||
permissions?: string[];
|
|
||||||
expiresInDays?: number;
|
|
||||||
};
|
|
||||||
const { name = "", permissions = [], expiresInDays } = body;
|
|
||||||
|
|
||||||
if (!name.trim()) {
|
|
||||||
return NextResponse.json({ error: "Token 名称不能为空" }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const token = generateToken();
|
|
||||||
const expiresAt = expiresInDays
|
|
||||||
? new Date(Date.now() + expiresInDays * 86400 * 1000)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const adminUserId = parseInt(req.headers.get("x-admin-user-id") || "0", 10);
|
|
||||||
const adminUsername = req.headers.get("x-admin-username") || "unknown";
|
|
||||||
|
|
||||||
const result = await createApiToken({
|
|
||||||
name: name.trim(),
|
|
||||||
token,
|
|
||||||
permissions,
|
|
||||||
createdBy: adminUserId,
|
|
||||||
expiresAt,
|
|
||||||
});
|
|
||||||
|
|
||||||
await createAuditLog({
|
|
||||||
userId: adminUserId,
|
|
||||||
username: adminUsername,
|
|
||||||
action: "create",
|
|
||||||
resource: "admin_api_token",
|
|
||||||
resourceId: String(result.id),
|
|
||||||
requestParams: { name: name.trim(), permissions, expiresInDays },
|
|
||||||
ipAddress: req.headers.get("x-forwarded-for") || undefined,
|
|
||||||
userAgent: req.headers.get("user-agent") || undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
return NextResponse.json({ id: result.id, token, name: name.trim(), expiresAt }, { status: 201 });
|
|
||||||
} catch (e) {
|
|
||||||
logError("Create token error:", e);
|
|
||||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,77 +0,0 @@
|
|||||||
import { logError } from "@/lib/logger";
|
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
|
||||||
import { login, buildSetCookieHeader } from "@/lib/auth";
|
|
||||||
import { createAuditLog } from "@/lib/log";
|
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
|
||||||
try {
|
|
||||||
const body = await req.json() as {
|
|
||||||
username?: string;
|
|
||||||
password?: string;
|
|
||||||
};
|
|
||||||
const { username = "", password = "" } = body;
|
|
||||||
|
|
||||||
if (!username || !password) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "用户名和密码不能为空" },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await login(username, password);
|
|
||||||
if (!result) {
|
|
||||||
// 记录登录失败
|
|
||||||
const ip = req.headers.get("x-forwarded-for") || req.headers.get("x-real-ip") || "unknown";
|
|
||||||
const ua = req.headers.get("user-agent") || "unknown";
|
|
||||||
await createAuditLog({
|
|
||||||
userId: 0,
|
|
||||||
username,
|
|
||||||
action: "login",
|
|
||||||
resource: "auth",
|
|
||||||
result: "failure",
|
|
||||||
errorMessage: "Invalid credentials",
|
|
||||||
ipAddress: ip,
|
|
||||||
userAgent: ua,
|
|
||||||
});
|
|
||||||
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "用户名或密码错误" },
|
|
||||||
{ status: 401 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 记录登录成功
|
|
||||||
const ip = req.headers.get("x-forwarded-for") || req.headers.get("x-real-ip") || "unknown";
|
|
||||||
const ua = req.headers.get("user-agent") || "unknown";
|
|
||||||
await createAuditLog({
|
|
||||||
userId: result.adminSession.userId,
|
|
||||||
username: result.adminSession.username,
|
|
||||||
action: "login",
|
|
||||||
resource: "auth",
|
|
||||||
result: "success",
|
|
||||||
ipAddress: ip,
|
|
||||||
userAgent: ua,
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = NextResponse.json({
|
|
||||||
user: {
|
|
||||||
id: result.adminSession.userId,
|
|
||||||
username: result.adminSession.username,
|
|
||||||
roles: result.adminSession.roles,
|
|
||||||
permissions: result.adminSession.permissions,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
response.headers.set(
|
|
||||||
"Set-Cookie",
|
|
||||||
buildSetCookieHeader(result.sessionId)
|
|
||||||
);
|
|
||||||
|
|
||||||
return response;
|
|
||||||
} catch (e) {
|
|
||||||
logError("Login error:", e);
|
|
||||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,40 +0,0 @@
|
|||||||
import { logError } from "@/lib/logger";
|
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
|
||||||
import { logout, parseSessionCookie, loadAdminSession, buildClearCookieHeader } from "@/lib/auth";
|
|
||||||
import { createAuditLog } from "@/lib/log";
|
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
|
||||||
try {
|
|
||||||
const cookieHeader = req.headers.get("cookie");
|
|
||||||
const sessionId = parseSessionCookie(cookieHeader);
|
|
||||||
|
|
||||||
if (sessionId) {
|
|
||||||
const session = await loadAdminSession(sessionId);
|
|
||||||
if (session) {
|
|
||||||
const ip = req.headers.get("x-forwarded-for") || req.headers.get("x-real-ip") || "unknown";
|
|
||||||
const ua = req.headers.get("user-agent") || "unknown";
|
|
||||||
await createAuditLog({
|
|
||||||
userId: session.userId,
|
|
||||||
username: session.username,
|
|
||||||
action: "logout",
|
|
||||||
resource: "auth",
|
|
||||||
result: "success",
|
|
||||||
ipAddress: ip,
|
|
||||||
userAgent: ua,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
await logout(sessionId);
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = NextResponse.json({ success: true });
|
|
||||||
response.headers.set("Set-Cookie", buildClearCookieHeader());
|
|
||||||
return response;
|
|
||||||
} catch (e) {
|
|
||||||
logError("Logout error:", e);
|
|
||||||
const response = NextResponse.json({ success: false });
|
|
||||||
response.headers.set("Set-Cookie", buildClearCookieHeader());
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,36 +0,0 @@
|
|||||||
import { logError } from "@/lib/logger";
|
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
|
||||||
import { parseSessionCookie, loadAdminSession, touchSession } from "@/lib/auth";
|
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
|
||||||
try {
|
|
||||||
const cookieHeader = req.headers.get("cookie");
|
|
||||||
const sessionId = parseSessionCookie(cookieHeader);
|
|
||||||
|
|
||||||
if (!sessionId) {
|
|
||||||
return NextResponse.json({ user: null });
|
|
||||||
}
|
|
||||||
|
|
||||||
const session = await loadAdminSession(sessionId);
|
|
||||||
if (!session) {
|
|
||||||
return NextResponse.json({ user: null });
|
|
||||||
}
|
|
||||||
|
|
||||||
// 刷新活跃时间
|
|
||||||
await touchSession(sessionId);
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
user: {
|
|
||||||
id: session.userId,
|
|
||||||
username: session.username,
|
|
||||||
roles: session.roles,
|
|
||||||
permissions: session.permissions,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
logError("Session check error:", e);
|
|
||||||
return NextResponse.json({ user: null });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,13 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
|
||||||
import { buildOidcAuthUrl, isOidcEnabled } from "@/lib/auth";
|
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
|
||||||
if (!isOidcEnabled()) {
|
|
||||||
return NextResponse.json({ error: "OIDC 未启用" }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const authUrl = buildOidcAuthUrl();
|
|
||||||
return NextResponse.redirect(authUrl);
|
|
||||||
}
|
|
||||||
@ -1,44 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
|
||||||
import { exchangeOidcCode, buildSetCookieHeader, isOidcEnabled } from "@/lib/auth";
|
|
||||||
import { createAuditLog } from "@/lib/log";
|
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
|
||||||
if (!isOidcEnabled()) {
|
|
||||||
return NextResponse.redirect(new URL("/login?error=oidc_disabled", req.url));
|
|
||||||
}
|
|
||||||
|
|
||||||
const code = req.nextUrl.searchParams.get("code");
|
|
||||||
const error = req.nextUrl.searchParams.get("error");
|
|
||||||
|
|
||||||
if (error || !code) {
|
|
||||||
return NextResponse.redirect(
|
|
||||||
new URL(`/login?error=${encodeURIComponent(error || "auth_failed")}`, req.url)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await exchangeOidcCode(code);
|
|
||||||
if (!result) {
|
|
||||||
return NextResponse.redirect(new URL("/login?error=auth_failed", req.url));
|
|
||||||
}
|
|
||||||
|
|
||||||
const ip = req.headers.get("x-forwarded-for") || req.headers.get("x-real-ip") || "unknown";
|
|
||||||
const ua = req.headers.get("user-agent") || "unknown";
|
|
||||||
await createAuditLog({
|
|
||||||
userId: result.adminSession.userId,
|
|
||||||
username: result.adminSession.username,
|
|
||||||
action: "login",
|
|
||||||
resource: "auth/oidc",
|
|
||||||
result: "success",
|
|
||||||
ipAddress: ip,
|
|
||||||
userAgent: ua,
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = NextResponse.redirect(new URL("/", req.url));
|
|
||||||
response.headers.set(
|
|
||||||
"Set-Cookie",
|
|
||||||
buildSetCookieHeader(result.sessionId)
|
|
||||||
);
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
@ -1,139 +0,0 @@
|
|||||||
import { logError } from "@/lib/logger";
|
|
||||||
import { NextResponse } from "next/server";
|
|
||||||
import { query } from "@/lib/db";
|
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
|
||||||
|
|
||||||
let migrationDone = false;
|
|
||||||
|
|
||||||
async function ensureTables() {
|
|
||||||
if (migrationDone) return;
|
|
||||||
migrationDone = true;
|
|
||||||
|
|
||||||
console.log("[Health] Checking database tables...");
|
|
||||||
|
|
||||||
// 1. admin_audit_log
|
|
||||||
await query(`
|
|
||||||
CREATE TABLE IF NOT EXISTS admin_audit_log (
|
|
||||||
id BIGSERIAL PRIMARY KEY,
|
|
||||||
user_id INTEGER NOT NULL,
|
|
||||||
username VARCHAR(255) NOT NULL,
|
|
||||||
action VARCHAR(50) NOT NULL,
|
|
||||||
resource VARCHAR(255) NOT NULL,
|
|
||||||
resource_id VARCHAR(255),
|
|
||||||
request_params JSONB,
|
|
||||||
ip_address VARCHAR(255),
|
|
||||||
user_agent TEXT,
|
|
||||||
result VARCHAR(20) NOT NULL DEFAULT 'success',
|
|
||||||
error_message TEXT,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
|
|
||||||
// 2. admin_user
|
|
||||||
await query(`
|
|
||||||
CREATE TABLE IF NOT EXISTS admin_user (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
username VARCHAR(255) UNIQUE NOT NULL,
|
|
||||||
password_hash VARCHAR(255) NOT NULL,
|
|
||||||
is_active BOOLEAN NOT NULL DEFAULT true,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
|
|
||||||
// 3. admin_role
|
|
||||||
await query(`
|
|
||||||
CREATE TABLE IF NOT EXISTS admin_role (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
name VARCHAR(255) UNIQUE NOT NULL,
|
|
||||||
description TEXT,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
|
|
||||||
// 4. admin_permission
|
|
||||||
await query(`
|
|
||||||
CREATE TABLE IF NOT EXISTS admin_permission (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
name VARCHAR(255) NOT NULL,
|
|
||||||
code VARCHAR(255) UNIQUE NOT NULL,
|
|
||||||
description TEXT,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
|
|
||||||
// 5. admin_user_role
|
|
||||||
await query(`
|
|
||||||
CREATE TABLE IF NOT EXISTS admin_user_role (
|
|
||||||
user_id INTEGER NOT NULL REFERENCES admin_user(id) ON DELETE CASCADE,
|
|
||||||
role_id INTEGER NOT NULL REFERENCES admin_role(id) ON DELETE CASCADE,
|
|
||||||
PRIMARY KEY (user_id, role_id)
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
|
|
||||||
// 6. admin_role_permission
|
|
||||||
await query(`
|
|
||||||
CREATE TABLE IF NOT EXISTS admin_role_permission (
|
|
||||||
role_id INTEGER NOT NULL REFERENCES admin_role(id) ON DELETE CASCADE,
|
|
||||||
permission_id INTEGER NOT NULL REFERENCES admin_permission(id) ON DELETE CASCADE,
|
|
||||||
PRIMARY KEY (role_id, permission_id)
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
|
|
||||||
// 索引
|
|
||||||
await query(`CREATE INDEX IF NOT EXISTS idx_admin_user_username ON admin_user(username)`);
|
|
||||||
await query(`CREATE INDEX IF NOT EXISTS idx_admin_audit_log_user_id ON admin_audit_log(user_id)`);
|
|
||||||
await query(`CREATE INDEX IF NOT EXISTS idx_admin_audit_log_created_at ON admin_audit_log(created_at DESC)`);
|
|
||||||
await query(`CREATE INDEX IF NOT EXISTS idx_admin_audit_log_action ON admin_audit_log(action)`);
|
|
||||||
await query(`CREATE INDEX IF NOT EXISTS idx_admin_audit_log_resource ON admin_audit_log(resource)`);
|
|
||||||
|
|
||||||
// Seed data
|
|
||||||
await query(`
|
|
||||||
INSERT INTO admin_permission (name, code, description) VALUES
|
|
||||||
('用户管理', 'user:read', '查看用户列表'),
|
|
||||||
('用户创建', 'user:create', '创建管理员用户'),
|
|
||||||
('用户更新', 'user:update', '更新管理员用户'),
|
|
||||||
('用户删除', 'user:delete', '删除管理员用户'),
|
|
||||||
('角色管理', 'role:read', '查看角色'),
|
|
||||||
('角色创建', 'role:create', '创建角色'),
|
|
||||||
('角色更新', 'role:update', '更新角色'),
|
|
||||||
('角色删除', 'role:delete', '删除角色'),
|
|
||||||
('权限管理', 'permission:read', '查看权限'),
|
|
||||||
('权限创建', 'permission:create', '创建权限'),
|
|
||||||
('权限更新', 'permission:update', '更新权限'),
|
|
||||||
('权限删除', 'permission:delete', '删除权限'),
|
|
||||||
('日志查看', 'log:read', '查看审计日志'),
|
|
||||||
('会话管理', 'session:manage', '管理在线用户会话'),
|
|
||||||
('平台数据', 'platform:read', '查看平台数据'),
|
|
||||||
('平台管理', 'platform:manage', '管理平台数据')
|
|
||||||
ON CONFLICT (code) DO NOTHING
|
|
||||||
`);
|
|
||||||
|
|
||||||
await query(`
|
|
||||||
INSERT INTO admin_role (name, description) VALUES
|
|
||||||
('超级管理员', '拥有所有权限')
|
|
||||||
ON CONFLICT (name) DO NOTHING
|
|
||||||
`);
|
|
||||||
|
|
||||||
await query(`
|
|
||||||
INSERT INTO admin_role_permission (role_id, permission_id)
|
|
||||||
SELECT r.id, p.id
|
|
||||||
FROM admin_role r
|
|
||||||
CROSS JOIN admin_permission p
|
|
||||||
WHERE r.name = '超级管理员'
|
|
||||||
ON CONFLICT DO NOTHING
|
|
||||||
`);
|
|
||||||
|
|
||||||
console.log("[Health] Database tables ready");
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
try {
|
|
||||||
await ensureTables();
|
|
||||||
return NextResponse.json({ status: "ok" }, { status: 200 });
|
|
||||||
} catch (e) {
|
|
||||||
logError("[Health] DB check failed:", e);
|
|
||||||
return NextResponse.json({ status: "error", reason: String(e) }, { status: 503 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,94 +0,0 @@
|
|||||||
import { logError } from "@/lib/logger";
|
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
|
||||||
import { listAuditLogs } from "@/lib/log";
|
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
|
||||||
try {
|
|
||||||
const { searchParams } = req.nextUrl;
|
|
||||||
const format = searchParams.get("format") || "json";
|
|
||||||
|
|
||||||
// CSV export mode
|
|
||||||
if (format === "csv") {
|
|
||||||
const action = searchParams.get("action") || undefined;
|
|
||||||
const resource = searchParams.get("resource") || undefined;
|
|
||||||
const startDate = searchParams.get("startDate")
|
|
||||||
? new Date(searchParams.get("startDate")!)
|
|
||||||
: undefined;
|
|
||||||
const endDate = searchParams.get("endDate")
|
|
||||||
? new Date(searchParams.get("endDate")!)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
// Fetch all matching rows (up to 10,000) for CSV export
|
|
||||||
const result = await listAuditLogs({
|
|
||||||
page: 1,
|
|
||||||
pageSize: 10000,
|
|
||||||
action: action || undefined,
|
|
||||||
resource: resource || undefined,
|
|
||||||
startDate,
|
|
||||||
endDate,
|
|
||||||
});
|
|
||||||
|
|
||||||
const logs = result.logs as unknown as Array<{
|
|
||||||
id: number; user_id: number; username: string; action: string;
|
|
||||||
resource: string; resource_id: string | null; result: string;
|
|
||||||
ip_address: string | null; created_at: Date;
|
|
||||||
}>;
|
|
||||||
|
|
||||||
const header = ["ID", "时间", "用户", "用户ID", "操作", "资源", "资源ID", "结果", "IP地址"];
|
|
||||||
const rows = logs.map((log) => [
|
|
||||||
log.id,
|
|
||||||
new Date(log.created_at).toISOString(),
|
|
||||||
log.username,
|
|
||||||
log.user_id,
|
|
||||||
log.action,
|
|
||||||
log.resource,
|
|
||||||
log.resource_id || "",
|
|
||||||
log.result,
|
|
||||||
log.ip_address || "",
|
|
||||||
]);
|
|
||||||
|
|
||||||
const csvContent = [header, ...rows]
|
|
||||||
.map((row) => row.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(","))
|
|
||||||
.join("\n");
|
|
||||||
|
|
||||||
const filename = `audit-log-${new Date().toISOString().slice(0, 10)}.csv`;
|
|
||||||
|
|
||||||
return new NextResponse(csvContent, {
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "text/csv; charset=utf-8",
|
|
||||||
"Content-Disposition": `attachment; filename="${filename}"`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Normal JSON pagination mode
|
|
||||||
const page = Math.max(1, parseInt(searchParams.get("page") || "1", 10));
|
|
||||||
const pageSize = Math.max(1, parseInt(searchParams.get("pageSize") || "20", 10));
|
|
||||||
const userId = searchParams.get("userId")
|
|
||||||
? parseInt(searchParams.get("userId")!, 10)
|
|
||||||
: undefined;
|
|
||||||
const action = searchParams.get("action") || undefined;
|
|
||||||
const resource = searchParams.get("resource") || undefined;
|
|
||||||
const startDate = searchParams.get("startDate")
|
|
||||||
? new Date(searchParams.get("startDate")!)
|
|
||||||
: undefined;
|
|
||||||
const endDate = searchParams.get("endDate")
|
|
||||||
? new Date(searchParams.get("endDate")!)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const result = await listAuditLogs({
|
|
||||||
page,
|
|
||||||
pageSize,
|
|
||||||
userId,
|
|
||||||
action: action || undefined,
|
|
||||||
resource: resource || undefined,
|
|
||||||
startDate,
|
|
||||||
endDate,
|
|
||||||
});
|
|
||||||
|
|
||||||
return NextResponse.json(result);
|
|
||||||
} catch (e) {
|
|
||||||
logError("List audit logs error:", e);
|
|
||||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,74 +0,0 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
import { Registry, Gauge } from "prom-client";
|
|
||||||
import { query } from "@/lib/db";
|
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Prometheus-compatible metrics endpoint.
|
|
||||||
*
|
|
||||||
* Usage in prometheus.yml:
|
|
||||||
* - job_name: 'admin-metrics'
|
|
||||||
* scrape_interval: 60s
|
|
||||||
* static_configs:
|
|
||||||
* - targets: ['admin:3000']
|
|
||||||
* metrics_path: '/api/metrics/prometheus'
|
|
||||||
*/
|
|
||||||
export async function GET() {
|
|
||||||
const register = new Registry();
|
|
||||||
|
|
||||||
const gauge = new Gauge({
|
|
||||||
name: "platform_entity_count",
|
|
||||||
help: "Platform entity counts by time window",
|
|
||||||
labelNames: ["entity", "window"] as const,
|
|
||||||
registers: [register],
|
|
||||||
});
|
|
||||||
|
|
||||||
const entities = [
|
|
||||||
{ name: "users", table: '"user"' },
|
|
||||||
{ name: "workspaces", table: "workspace" },
|
|
||||||
{ name: "projects", table: "project" },
|
|
||||||
{ name: "repos", table: "repo" },
|
|
||||||
{ name: "rooms", table: "room" },
|
|
||||||
{ name: "skills", table: "project_skill" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const results = await Promise.all(
|
|
||||||
entities.map(async ({ name, table }) => {
|
|
||||||
const res = await query<{
|
|
||||||
total: string;
|
|
||||||
new_27h: string;
|
|
||||||
new_7d: string;
|
|
||||||
new_30d: string;
|
|
||||||
}>(
|
|
||||||
`SELECT
|
|
||||||
COUNT(*) AS total,
|
|
||||||
COUNT(*) FILTER (WHERE created_at >= NOW() - INTERVAL '27 hours') AS new_27h,
|
|
||||||
COUNT(*) FILTER (WHERE created_at >= NOW() - INTERVAL '7 days') AS new_7d,
|
|
||||||
COUNT(*) FILTER (WHERE created_at >= NOW() - INTERVAL '30 days') AS new_30d
|
|
||||||
FROM ${table}`
|
|
||||||
);
|
|
||||||
const row = res.rows[0];
|
|
||||||
return {
|
|
||||||
name,
|
|
||||||
total: parseInt(row.total, 10),
|
|
||||||
new_27h: parseInt(row.new_27h, 10),
|
|
||||||
new_7d: parseInt(row.new_7d, 10),
|
|
||||||
new_30d: parseInt(row.new_30d, 10),
|
|
||||||
};
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const r of results) {
|
|
||||||
gauge.set({ entity: r.name, window: "total" }, r.total);
|
|
||||||
gauge.set({ entity: r.name, window: "27h" }, r.new_27h);
|
|
||||||
gauge.set({ entity: r.name, window: "7d" }, r.new_7d);
|
|
||||||
gauge.set({ entity: r.name, window: "30d" }, r.new_30d);
|
|
||||||
}
|
|
||||||
|
|
||||||
const metrics = await register.metrics();
|
|
||||||
|
|
||||||
return new NextResponse(metrics, {
|
|
||||||
headers: { "Content-Type": register.contentType },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@ -1,65 +0,0 @@
|
|||||||
import { logError } from "@/lib/logger";
|
|
||||||
import { NextResponse } from "next/server";
|
|
||||||
import { query } from "@/lib/db";
|
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Platform metrics endpoint.
|
|
||||||
* Returns total / 27h / 7d / 30d new counts for all platform entities.
|
|
||||||
*/
|
|
||||||
export async function GET() {
|
|
||||||
try {
|
|
||||||
// Query all entity counts in parallel
|
|
||||||
// Tables: user, workspace, project, repo, room, project_skill
|
|
||||||
const entities = [
|
|
||||||
{ name: "users", table: '"user"' },
|
|
||||||
{ name: "workspaces", table: "workspace" },
|
|
||||||
{ name: "projects", table: "project" },
|
|
||||||
{ name: "repos", table: "repo" },
|
|
||||||
{ name: "rooms", table: "room" },
|
|
||||||
{ name: "skills", table: "project_skill" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const results = await Promise.all(
|
|
||||||
entities.map(async ({ name, table }) => {
|
|
||||||
const res = await query<{
|
|
||||||
total: string;
|
|
||||||
new_27h: string;
|
|
||||||
new_7d: string;
|
|
||||||
new_30d: string;
|
|
||||||
}>(
|
|
||||||
`SELECT
|
|
||||||
COUNT(*) AS total,
|
|
||||||
COUNT(*) FILTER (WHERE created_at >= NOW() - INTERVAL '27 hours') AS new_27h,
|
|
||||||
COUNT(*) FILTER (WHERE created_at >= NOW() - INTERVAL '7 days') AS new_7d,
|
|
||||||
COUNT(*) FILTER (WHERE created_at >= NOW() - INTERVAL '30 days') AS new_30d
|
|
||||||
FROM ${table}`
|
|
||||||
);
|
|
||||||
const row = res.rows[0];
|
|
||||||
return {
|
|
||||||
name,
|
|
||||||
total: parseInt(row.total, 10),
|
|
||||||
new_27h: parseInt(row.new_27h, 10),
|
|
||||||
new_7d: parseInt(row.new_7d, 10),
|
|
||||||
new_30d: parseInt(row.new_30d, 10),
|
|
||||||
};
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
const metrics: Record<string, unknown> = {};
|
|
||||||
for (const r of results) {
|
|
||||||
metrics[r.name] = {
|
|
||||||
total: r.total,
|
|
||||||
last_27h: r.new_27h,
|
|
||||||
last_7d: r.new_7d,
|
|
||||||
last_30d: r.new_30d,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({ metrics, timestamp: new Date().toISOString() });
|
|
||||||
} catch (e) {
|
|
||||||
logError("Metrics error:", e);
|
|
||||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,132 +0,0 @@
|
|||||||
import { logError } from "@/lib/logger";
|
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
|
||||||
import {
|
|
||||||
listPermissions,
|
|
||||||
createPermission,
|
|
||||||
updatePermission,
|
|
||||||
deletePermission,
|
|
||||||
} from "@/lib/rbac";
|
|
||||||
import { createAuditLog } from "@/lib/log";
|
|
||||||
|
|
||||||
function getAuthInfo(req: NextRequest) {
|
|
||||||
return {
|
|
||||||
userId: parseInt(req.headers.get("x-admin-user-id") || "0", 10),
|
|
||||||
username: req.headers.get("x-admin-username") || "unknown",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
try {
|
|
||||||
const permissions = await listPermissions();
|
|
||||||
return NextResponse.json({ permissions });
|
|
||||||
} catch (e) {
|
|
||||||
logError("List permissions error:", e);
|
|
||||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
|
||||||
try {
|
|
||||||
const body = await req.json() as {
|
|
||||||
name?: string;
|
|
||||||
code?: string;
|
|
||||||
description?: string;
|
|
||||||
};
|
|
||||||
const { name = "", code = "", description = "" } = body;
|
|
||||||
|
|
||||||
if (!name || !code) {
|
|
||||||
return NextResponse.json({ error: "名称和代码不能为空" }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!/^[a-z0-9_:]+$/.test(code)) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "代码只能包含小写字母、数字、冒号和下划线" },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const permission = await createPermission(name, code, description);
|
|
||||||
|
|
||||||
const { userId, username } = getAuthInfo(req);
|
|
||||||
await createAuditLog({
|
|
||||||
userId,
|
|
||||||
username,
|
|
||||||
action: "create",
|
|
||||||
resource: "admin_permission",
|
|
||||||
resourceId: String(permission.id),
|
|
||||||
requestParams: { name, code, description },
|
|
||||||
ipAddress: req.headers.get("x-forwarded-for") || undefined,
|
|
||||||
userAgent: req.headers.get("user-agent") || undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
return NextResponse.json(permission, { status: 201 });
|
|
||||||
} catch (e: unknown) {
|
|
||||||
if ((e as { code?: string }).code === "23505") {
|
|
||||||
return NextResponse.json({ error: "权限代码已存在" }, { status: 409 });
|
|
||||||
}
|
|
||||||
logError("Create permission error:", e);
|
|
||||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function PUT(req: NextRequest) {
|
|
||||||
try {
|
|
||||||
const body = await req.json() as {
|
|
||||||
id?: number;
|
|
||||||
name?: string;
|
|
||||||
code?: string;
|
|
||||||
description?: string;
|
|
||||||
};
|
|
||||||
const { id, name = "", code = "", description = "" } = body;
|
|
||||||
|
|
||||||
if (!id) return NextResponse.json({ error: "ID 不能为空" }, { status: 400 });
|
|
||||||
|
|
||||||
const permission = await updatePermission(id, name, code, description);
|
|
||||||
if (!permission) return NextResponse.json({ error: "权限不存在" }, { status: 404 });
|
|
||||||
|
|
||||||
const { userId, username } = getAuthInfo(req);
|
|
||||||
await createAuditLog({
|
|
||||||
userId,
|
|
||||||
username,
|
|
||||||
action: "update",
|
|
||||||
resource: "admin_permission",
|
|
||||||
resourceId: String(id),
|
|
||||||
requestParams: { name, code, description },
|
|
||||||
ipAddress: req.headers.get("x-forwarded-for") || undefined,
|
|
||||||
userAgent: req.headers.get("user-agent") || undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
return NextResponse.json(permission);
|
|
||||||
} catch (e) {
|
|
||||||
logError("Update permission error:", e);
|
|
||||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function DELETE(req: NextRequest) {
|
|
||||||
try {
|
|
||||||
const { searchParams } = req.nextUrl;
|
|
||||||
const id = parseInt(searchParams.get("id") || "0", 10);
|
|
||||||
|
|
||||||
if (!id) return NextResponse.json({ error: "ID 不能为空" }, { status: 400 });
|
|
||||||
|
|
||||||
const ok = await deletePermission(id);
|
|
||||||
if (!ok) return NextResponse.json({ error: "权限不存在" }, { status: 404 });
|
|
||||||
|
|
||||||
const { userId, username } = getAuthInfo(req);
|
|
||||||
await createAuditLog({
|
|
||||||
userId,
|
|
||||||
username,
|
|
||||||
action: "delete",
|
|
||||||
resource: "admin_permission",
|
|
||||||
resourceId: String(id),
|
|
||||||
ipAddress: req.headers.get("x-forwarded-for") || undefined,
|
|
||||||
userAgent: req.headers.get("user-agent") || undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
return NextResponse.json({ success: true });
|
|
||||||
} catch (e) {
|
|
||||||
logError("Delete permission error:", e);
|
|
||||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,83 +0,0 @@
|
|||||||
import { logError } from "@/lib/logger";
|
|
||||||
import { NextResponse } from "next/server";
|
|
||||||
import { query } from "@/lib/db";
|
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
try {
|
|
||||||
// Last 30 days of DAU
|
|
||||||
const dauResult = await query<{ date: string; dau: string }>(
|
|
||||||
`SELECT
|
|
||||||
TO_CHAR(DATE_TRUNC('day', created_at), 'YYYY-MM-DD') AS date,
|
|
||||||
COUNT(DISTINCT user_uid) AS dau
|
|
||||||
FROM user_activity_log
|
|
||||||
WHERE created_at >= NOW() - INTERVAL '30 days'
|
|
||||||
GROUP BY DATE_TRUNC('day', created_at)
|
|
||||||
ORDER BY date ASC`
|
|
||||||
);
|
|
||||||
|
|
||||||
// Last 7 days breakdown by action
|
|
||||||
const actionBreakdown = await query<{ date: string; action: string; count: string }>(
|
|
||||||
`SELECT
|
|
||||||
TO_CHAR(DATE_TRUNC('day', created_at), 'YYYY-MM-DD') AS date,
|
|
||||||
action,
|
|
||||||
COUNT(*) AS count
|
|
||||||
FROM user_activity_log
|
|
||||||
WHERE created_at >= NOW() - INTERVAL '7 days'
|
|
||||||
GROUP BY DATE_TRUNC('day', created_at), action
|
|
||||||
ORDER BY date ASC, action ASC`
|
|
||||||
);
|
|
||||||
|
|
||||||
// MAU: unique users in last 30 days
|
|
||||||
const mauResult = await query<{ mau: string }>(
|
|
||||||
`SELECT COUNT(DISTINCT user_uid) AS mau
|
|
||||||
FROM user_activity_log
|
|
||||||
WHERE created_at >= NOW() - INTERVAL '30 days'`
|
|
||||||
);
|
|
||||||
|
|
||||||
// Total logins (ever)
|
|
||||||
const totalLogins = await query<{ count: string }>(
|
|
||||||
`SELECT COUNT(*) AS count FROM user_activity_log WHERE action = 'login'`
|
|
||||||
);
|
|
||||||
|
|
||||||
// Login stats last 30 days
|
|
||||||
const loginStats = await query<{ date: string; logins: string }>(
|
|
||||||
`SELECT
|
|
||||||
TO_CHAR(DATE_TRUNC('day', created_at), 'YYYY-MM-DD') AS date,
|
|
||||||
COUNT(*) AS logins
|
|
||||||
FROM user_activity_log
|
|
||||||
WHERE action = 'login' AND created_at >= NOW() - INTERVAL '30 days'
|
|
||||||
GROUP BY DATE_TRUNC('day', created_at)
|
|
||||||
ORDER BY date ASC`
|
|
||||||
);
|
|
||||||
|
|
||||||
const dau = dauResult.rows.map((r) => ({
|
|
||||||
date: r.date,
|
|
||||||
dau: parseInt(r.dau, 10),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const mau = parseInt(mauResult.rows[0]?.mau || "0", 10);
|
|
||||||
const totalLoginCount = parseInt(totalLogins.rows[0]?.count || "0", 10);
|
|
||||||
const logins = loginStats.rows.map((r) => ({
|
|
||||||
date: r.date,
|
|
||||||
logins: parseInt(r.logins, 10),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Recent activity count (last 24h)
|
|
||||||
const last24h = await query<{ count: string }>(
|
|
||||||
`SELECT COUNT(*) AS count FROM user_activity_log WHERE created_at >= NOW() - INTERVAL '24 hours'`
|
|
||||||
);
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
dau,
|
|
||||||
mau,
|
|
||||||
totalLogins: totalLoginCount,
|
|
||||||
logins,
|
|
||||||
last24h: parseInt(last24h.rows[0]?.count || "0", 10),
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
logError("Activity stats error:", e);
|
|
||||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,104 +0,0 @@
|
|||||||
import { logError } from "@/lib/logger";
|
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
|
||||||
import { query } from "@/lib/db";
|
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
|
||||||
try {
|
|
||||||
const { searchParams } = req.nextUrl;
|
|
||||||
const type = searchParams.get("type") || "all";
|
|
||||||
|
|
||||||
const [providersData, modelsData, pricingData, versionsData] = await Promise.all([
|
|
||||||
query(
|
|
||||||
`SELECT id, name, display_name, website, status, created_at, updated_at
|
|
||||||
FROM ai_model_provider
|
|
||||||
ORDER BY name`
|
|
||||||
),
|
|
||||||
query(
|
|
||||||
`SELECT m.id, m.name, m.modality, m.capability, m.context_length,
|
|
||||||
m.max_output_tokens, m.training_cutoff, m.is_open_source, m.status,
|
|
||||||
mv.model_id, mv.version,
|
|
||||||
p.id as provider_id, p.name as provider_name
|
|
||||||
FROM ai_model m
|
|
||||||
JOIN ai_model_provider p ON p.id = m.provider_id
|
|
||||||
LEFT JOIN ai_model_version mv ON mv.model_id = m.id AND mv.is_default = true
|
|
||||||
ORDER BY p.name, m.name`
|
|
||||||
),
|
|
||||||
query(
|
|
||||||
`SELECT mp.id, mp.model_version_id, mp.input_price_per_1k_tokens, mp.output_price_per_1k_tokens,
|
|
||||||
mp.currency, mp.effective_from,
|
|
||||||
m.name as model_name, mv.model_id
|
|
||||||
FROM ai_model_pricing mp
|
|
||||||
JOIN ai_model_version mv ON mv.id = mp.model_version_id
|
|
||||||
JOIN ai_model m ON m.id = mv.model_id
|
|
||||||
ORDER BY mp.effective_from DESC
|
|
||||||
LIMIT 200`
|
|
||||||
),
|
|
||||||
query(
|
|
||||||
`SELECT mv.id, mv.model_id, mv.version, mv.release_date, mv.change_log, mv.is_default, mv.status, mv.created_at
|
|
||||||
FROM ai_model_version mv
|
|
||||||
ORDER BY mv.model_id, mv.version`
|
|
||||||
),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const providersList = providersData.rows.map((r: Record<string, unknown>) => ({
|
|
||||||
id: String(r.id),
|
|
||||||
name: r.name,
|
|
||||||
display_name: r.display_name,
|
|
||||||
website: r.website,
|
|
||||||
status: r.status,
|
|
||||||
created_at: r.created_at,
|
|
||||||
updated_at: r.updated_at,
|
|
||||||
enabled: r.status === "active",
|
|
||||||
}));
|
|
||||||
|
|
||||||
const modelsList = modelsData.rows.map((r: Record<string, unknown>) => ({
|
|
||||||
id: String(r.id),
|
|
||||||
name: r.name,
|
|
||||||
model_id: r.model_id ? `${r.model_id}/${r.version}` : r.id,
|
|
||||||
modality: r.modality || "text",
|
|
||||||
capability: r.capability || "chat",
|
|
||||||
context_length: Number(r.context_length) || 8192,
|
|
||||||
max_output_tokens: r.max_output_tokens ? Number(r.max_output_tokens) : null,
|
|
||||||
training_cutoff: r.training_cutoff,
|
|
||||||
is_open_source: r.is_open_source ?? false,
|
|
||||||
enabled: r.status === "active",
|
|
||||||
status: r.status,
|
|
||||||
provider_id: String(r.provider_id),
|
|
||||||
provider_name: r.provider_name,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const pricingList = pricingData.rows.map((r: Record<string, unknown>) => ({
|
|
||||||
id: Number(r.id),
|
|
||||||
model_version_id: String(r.model_version_id),
|
|
||||||
model_id: r.model_id,
|
|
||||||
input_price_per_1k_tokens: String(r.input_price_per_1k_tokens),
|
|
||||||
output_price_per_1k_tokens: String(r.output_price_per_1k_tokens),
|
|
||||||
currency: r.currency,
|
|
||||||
effective_from: r.effective_from,
|
|
||||||
model_name: r.model_name,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const versionsList = versionsData.rows.map((r: Record<string, unknown>) => ({
|
|
||||||
id: String(r.id),
|
|
||||||
model_id: String(r.model_id),
|
|
||||||
version: r.version,
|
|
||||||
release_date: r.release_date,
|
|
||||||
change_log: r.change_log,
|
|
||||||
is_default: r.is_default ?? false,
|
|
||||||
status: r.status,
|
|
||||||
created_at: r.created_at,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
providers: providersList,
|
|
||||||
models: modelsList,
|
|
||||||
pricing: pricingList,
|
|
||||||
versions: versionsList,
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
logError("AI data error:", e);
|
|
||||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,128 +0,0 @@
|
|||||||
import { logError } from "@/lib/logger";
|
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
|
||||||
import { query } from "@/lib/db";
|
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
|
||||||
|
|
||||||
interface AuditLog {
|
|
||||||
source: "user_activity" | "project_audit";
|
|
||||||
id: number;
|
|
||||||
actor_uid: string | null;
|
|
||||||
action: string;
|
|
||||||
resource: string | null;
|
|
||||||
ip_address: string | null;
|
|
||||||
user_agent: string | null;
|
|
||||||
created_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
|
||||||
try {
|
|
||||||
const { searchParams } = req.nextUrl;
|
|
||||||
const page = Math.max(1, parseInt(searchParams.get("page") || "1", 10));
|
|
||||||
const pageSize = Math.max(1, parseInt(searchParams.get("pageSize") || "20", 10));
|
|
||||||
const source = searchParams.get("source") || "all";
|
|
||||||
const action = searchParams.get("action") || "";
|
|
||||||
const offset = (page - 1) * pageSize;
|
|
||||||
|
|
||||||
const actionPattern = action ? `%${action}%` : null;
|
|
||||||
const limitOffsetParams: unknown[] = [pageSize, offset];
|
|
||||||
|
|
||||||
let userQuery = "";
|
|
||||||
let projectQuery = "";
|
|
||||||
let userParams: unknown[] = [];
|
|
||||||
let projectParams: unknown[] = [];
|
|
||||||
|
|
||||||
// Build user_activity_log query
|
|
||||||
if (source !== "project") {
|
|
||||||
if (action) {
|
|
||||||
userParams = [actionPattern, ...limitOffsetParams];
|
|
||||||
userQuery = `SELECT 'user_activity' as source, id,
|
|
||||||
COALESCE(user_uid::text, '') as actor_uid,
|
|
||||||
action, NULL::text as resource,
|
|
||||||
ip_address, user_agent, created_at::text as created_at
|
|
||||||
FROM user_activity_log
|
|
||||||
WHERE action ILIKE $1
|
|
||||||
ORDER BY created_at DESC
|
|
||||||
LIMIT $2 OFFSET $3`;
|
|
||||||
} else {
|
|
||||||
userParams = limitOffsetParams;
|
|
||||||
userQuery = `SELECT 'user_activity' as source, id,
|
|
||||||
COALESCE(user_uid::text, '') as actor_uid,
|
|
||||||
action, NULL::text as resource,
|
|
||||||
ip_address, user_agent, created_at::text as created_at
|
|
||||||
FROM user_activity_log
|
|
||||||
ORDER BY created_at DESC
|
|
||||||
LIMIT $1 OFFSET $2`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build project_audit_log query
|
|
||||||
if (source !== "user") {
|
|
||||||
if (action) {
|
|
||||||
projectParams = [actionPattern, ...limitOffsetParams];
|
|
||||||
projectQuery = `SELECT 'project_audit' as source, id,
|
|
||||||
COALESCE(actor::text, '') as actor_uid,
|
|
||||||
action, details as resource, ip_address,
|
|
||||||
NULL as user_agent, created_at::text as created_at
|
|
||||||
FROM project_audit_log
|
|
||||||
WHERE action ILIKE $1
|
|
||||||
ORDER BY created_at DESC
|
|
||||||
LIMIT $2 OFFSET $3`;
|
|
||||||
} else {
|
|
||||||
projectParams = limitOffsetParams;
|
|
||||||
projectQuery = `SELECT 'project_audit' as source, id,
|
|
||||||
COALESCE(actor::text, '') as actor_uid,
|
|
||||||
action, details as resource, ip_address,
|
|
||||||
NULL as user_agent, created_at::text as created_at
|
|
||||||
FROM project_audit_log
|
|
||||||
ORDER BY created_at DESC
|
|
||||||
LIMIT $1 OFFSET $2`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const [userLogs, projectLogs] = await Promise.all([
|
|
||||||
userQuery ? query<AuditLog>(userQuery, userParams) : Promise.resolve({ rows: [] as AuditLog[] }),
|
|
||||||
projectQuery ? query<AuditLog>(projectQuery, projectParams) : Promise.resolve({ rows: [] as AuditLog[] }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// 合并并排序
|
|
||||||
const combined = [...userLogs.rows, ...projectLogs.rows].sort(
|
|
||||||
(a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
|
|
||||||
);
|
|
||||||
|
|
||||||
// 总数
|
|
||||||
const [userCountRes, projectCountRes] = await Promise.all([
|
|
||||||
userCountQuery(userParams, action),
|
|
||||||
projectCountQuery(projectParams, action),
|
|
||||||
]);
|
|
||||||
const total = parseInt(String(userCountRes.rows[0]?.count || "0"), 10) +
|
|
||||||
parseInt(String(projectCountRes.rows[0]?.count || "0"), 10);
|
|
||||||
|
|
||||||
return NextResponse.json({ logs: combined.slice(0, pageSize), total, page, pageSize });
|
|
||||||
} catch (e) {
|
|
||||||
logError("Platform audit logs error:", e);
|
|
||||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function userCountQuery(params: unknown[], action: string | null) {
|
|
||||||
if (!params.length) return { rows: [] as { count: string }[] };
|
|
||||||
if (action) {
|
|
||||||
return query<{ count: string }>(
|
|
||||||
`SELECT COUNT(*) FROM user_activity_log WHERE action ILIKE $1`,
|
|
||||||
[params[0]]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return query<{ count: string }>(`SELECT COUNT(*) FROM user_activity_log`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function projectCountQuery(params: unknown[], action: string | null) {
|
|
||||||
if (!params.length) return { rows: [] as { count: string }[] };
|
|
||||||
if (action) {
|
|
||||||
return query<{ count: string }>(
|
|
||||||
`SELECT COUNT(*) FROM project_audit_log WHERE action ILIKE $1`,
|
|
||||||
[params[0]]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return query<{ count: string }>(`SELECT COUNT(*) FROM project_audit_log`);
|
|
||||||
}
|
|
||||||
@ -1,97 +0,0 @@
|
|||||||
import { logError } from "@/lib/logger";
|
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
|
||||||
import { query } from "@/lib/db";
|
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
|
||||||
try {
|
|
||||||
const { searchParams } = req.nextUrl;
|
|
||||||
const page = Math.max(1, parseInt(searchParams.get("page") || "1", 10));
|
|
||||||
const pageSize = Math.max(1, parseInt(searchParams.get("pageSize") || "30", 10));
|
|
||||||
const projectId = searchParams.get("projectId") || "";
|
|
||||||
const search = searchParams.get("search") || "";
|
|
||||||
const offset = (page - 1) * pageSize;
|
|
||||||
|
|
||||||
const conditions: string[] = ["1=1"];
|
|
||||||
const params: (string | number)[] = [];
|
|
||||||
let paramIdx = 1;
|
|
||||||
|
|
||||||
if (projectId) {
|
|
||||||
conditions.push(`r.project = $${paramIdx++}`);
|
|
||||||
params.push(projectId);
|
|
||||||
}
|
|
||||||
if (search) {
|
|
||||||
conditions.push(`r.repo_name ILIKE $${paramIdx++}`);
|
|
||||||
params.push(`%${search}%`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const whereCond = conditions.join(" AND ");
|
|
||||||
const limitIdx = paramIdx++;
|
|
||||||
const offsetIdx = paramIdx++;
|
|
||||||
|
|
||||||
const [reposResult, countResult] = await Promise.all([
|
|
||||||
query<{
|
|
||||||
id: string;
|
|
||||||
repo_name: string;
|
|
||||||
project_id: string;
|
|
||||||
project_name: string;
|
|
||||||
workspace_name: string;
|
|
||||||
default_branch: string;
|
|
||||||
is_private: boolean;
|
|
||||||
created_by: string;
|
|
||||||
created_at: Date;
|
|
||||||
ai_code_review_enabled: boolean;
|
|
||||||
collaborator_count: string;
|
|
||||||
branch_count: string;
|
|
||||||
}>(
|
|
||||||
`SELECT
|
|
||||||
r.id, r.repo_name, r.project as project_id,
|
|
||||||
p.name as project_name,
|
|
||||||
COALESCE(w.name, '') as workspace_name,
|
|
||||||
r.default_branch, r.is_private,
|
|
||||||
r.created_by::text as created_by,
|
|
||||||
r.created_at::text as created_at,
|
|
||||||
r.ai_code_review_enabled,
|
|
||||||
(SELECT COUNT(*) FROM repo_collaborator WHERE repo = r.id)::text as collaborator_count,
|
|
||||||
(SELECT COUNT(*) FROM repo_branch WHERE repo = r.id)::text as branch_count
|
|
||||||
FROM repo r
|
|
||||||
JOIN project p ON p.id = r.project
|
|
||||||
LEFT JOIN workspace w ON w.id = p.workspace_id
|
|
||||||
WHERE ${whereCond}
|
|
||||||
ORDER BY r.created_at DESC
|
|
||||||
LIMIT $${limitIdx} OFFSET $${offsetIdx}`,
|
|
||||||
[...params, pageSize, offset]
|
|
||||||
),
|
|
||||||
query<{ count: string }>(
|
|
||||||
`SELECT COUNT(*)::text as count
|
|
||||||
FROM repo r
|
|
||||||
JOIN project p ON p.id = r.project
|
|
||||||
WHERE ${whereCond}`,
|
|
||||||
params
|
|
||||||
),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const repos = reposResult.rows.map((r) => ({
|
|
||||||
id: r.id,
|
|
||||||
repoName: r.repo_name,
|
|
||||||
projectId: r.project_id,
|
|
||||||
projectName: r.project_name,
|
|
||||||
workspaceName: r.workspace_name,
|
|
||||||
defaultBranch: r.default_branch,
|
|
||||||
isPrivate: r.is_private,
|
|
||||||
createdBy: r.created_by,
|
|
||||||
createdAt: String(r.created_at),
|
|
||||||
aiCodeReviewEnabled: r.ai_code_review_enabled,
|
|
||||||
collaboratorCount: parseInt(r.collaborator_count || "0", 10),
|
|
||||||
branchCount: parseInt(r.branch_count || "0", 10),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const total = parseInt(countResult.rows[0]?.count || "0", 10);
|
|
||||||
|
|
||||||
return NextResponse.json({ repos, total, page, pageSize });
|
|
||||||
} catch (e) {
|
|
||||||
logError("Repos error:", e);
|
|
||||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,37 +0,0 @@
|
|||||||
import { logError } from "@/lib/logger";
|
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
|
||||||
import { query } from "@/lib/db";
|
|
||||||
import { getAdminUserId } from "@/lib/auth";
|
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
|
||||||
|
|
||||||
export async function DELETE(
|
|
||||||
req: NextRequest,
|
|
||||||
{ params }: { params: Promise<{ id: string; msgId: string }> }
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const adminUserId = getAdminUserId(req);
|
|
||||||
if (!adminUserId) {
|
|
||||||
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const { id, msgId } = await params;
|
|
||||||
|
|
||||||
const result = await query<{ id: string }>(
|
|
||||||
`UPDATE room_message
|
|
||||||
SET revoked = NOW()
|
|
||||||
WHERE id = $1 AND room = $2 AND revoked IS NULL
|
|
||||||
RETURNING id`,
|
|
||||||
[msgId, id]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!result.rows.length) {
|
|
||||||
return NextResponse.json({ error: "消息不存在或已被撤回" }, { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({ success: true });
|
|
||||||
} catch (e) {
|
|
||||||
logError("Revoke message error:", e);
|
|
||||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,79 +0,0 @@
|
|||||||
import { logError } from "@/lib/logger";
|
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
|
||||||
import { query } from "@/lib/db";
|
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
|
||||||
|
|
||||||
export async function GET(
|
|
||||||
req: NextRequest,
|
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const { id } = await params;
|
|
||||||
const { searchParams } = req.nextUrl;
|
|
||||||
const page = Math.max(1, parseInt(searchParams.get("page") || "1", 10));
|
|
||||||
const pageSize = Math.max(1, parseInt(searchParams.get("pageSize") || "50", 10));
|
|
||||||
const offset = (page - 1) * pageSize;
|
|
||||||
|
|
||||||
const [messagesResult, countResult] = await Promise.all([
|
|
||||||
query<{
|
|
||||||
id: string;
|
|
||||||
seq: string;
|
|
||||||
sender_type: string;
|
|
||||||
sender_id: string | null;
|
|
||||||
sender_name: string | null;
|
|
||||||
content: string;
|
|
||||||
content_type: string;
|
|
||||||
send_at: Date;
|
|
||||||
revoked: Date | null;
|
|
||||||
in_reply_to: string | null;
|
|
||||||
}>(
|
|
||||||
`SELECT
|
|
||||||
m.id, m.seq, m.sender_type, m.sender_id,
|
|
||||||
CASE
|
|
||||||
WHEN m.sender_type = 'member' THEN COALESCE(u.display_name, u.username)
|
|
||||||
WHEN m.sender_type = 'ai' THEN 'AI'
|
|
||||||
WHEN m.sender_type = 'system' THEN 'System'
|
|
||||||
ELSE m.sender_id::text
|
|
||||||
END as sender_name,
|
|
||||||
CASE
|
|
||||||
WHEN m.sender_type = 'member' THEN 'user'
|
|
||||||
ELSE m.sender_type
|
|
||||||
END as sender_type,
|
|
||||||
m.content, m.content_type, m.send_at::text as send_at,
|
|
||||||
m.revoked::text as revoked,
|
|
||||||
m.in_reply_to::text as in_reply_to
|
|
||||||
FROM room_message m
|
|
||||||
LEFT JOIN "user" u ON u.uid = m.sender_id
|
|
||||||
WHERE m.room = $1
|
|
||||||
ORDER BY m.seq DESC
|
|
||||||
LIMIT $2 OFFSET $3`,
|
|
||||||
[id, pageSize, offset]
|
|
||||||
),
|
|
||||||
query<{ count: string }>(
|
|
||||||
`SELECT COUNT(*)::text as count FROM room_message WHERE room = $1`,
|
|
||||||
[id]
|
|
||||||
),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const messages = messagesResult.rows.map((m) => ({
|
|
||||||
id: m.id,
|
|
||||||
seq: parseInt(m.seq, 10),
|
|
||||||
senderType: m.sender_type,
|
|
||||||
senderId: m.sender_id,
|
|
||||||
senderName: m.sender_name,
|
|
||||||
content: m.content,
|
|
||||||
contentType: m.content_type,
|
|
||||||
sendAt: String(m.send_at),
|
|
||||||
revoked: m.revoked ? String(m.revoked) : null,
|
|
||||||
inReplyTo: m.in_reply_to,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const total = parseInt(countResult.rows[0]?.count || "0", 10);
|
|
||||||
|
|
||||||
return NextResponse.json({ messages, total, page, pageSize });
|
|
||||||
} catch (e) {
|
|
||||||
logError("Room messages error:", e);
|
|
||||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,97 +0,0 @@
|
|||||||
import { logError } from "@/lib/logger";
|
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
|
||||||
import { query } from "@/lib/db";
|
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
|
||||||
try {
|
|
||||||
const { searchParams } = req.nextUrl;
|
|
||||||
const page = Math.max(1, parseInt(searchParams.get("page") || "1", 10));
|
|
||||||
const pageSize = Math.max(1, parseInt(searchParams.get("pageSize") || "30", 10));
|
|
||||||
const projectId = searchParams.get("projectId") || "";
|
|
||||||
const search = searchParams.get("search") || "";
|
|
||||||
const offset = (page - 1) * pageSize;
|
|
||||||
|
|
||||||
const conditions: string[] = ["1=1"];
|
|
||||||
const params: (string | number)[] = [];
|
|
||||||
let paramIdx = 1;
|
|
||||||
|
|
||||||
if (projectId) {
|
|
||||||
conditions.push(`r.project = $${paramIdx++}`);
|
|
||||||
params.push(projectId);
|
|
||||||
}
|
|
||||||
if (search) {
|
|
||||||
conditions.push(`r.room_name ILIKE $${paramIdx++}`);
|
|
||||||
params.push(`%${search}%`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const whereCond = conditions.join(" AND ");
|
|
||||||
const limitIdx = paramIdx++;
|
|
||||||
const offsetIdx = paramIdx++;
|
|
||||||
|
|
||||||
const [roomsResult, countResult] = await Promise.all([
|
|
||||||
query<{
|
|
||||||
id: string;
|
|
||||||
room_name: string;
|
|
||||||
is_public: boolean;
|
|
||||||
project_id: string;
|
|
||||||
project_name: string;
|
|
||||||
workspace_name: string;
|
|
||||||
created_by: string;
|
|
||||||
created_at: Date;
|
|
||||||
last_msg_at: Date;
|
|
||||||
member_count: string;
|
|
||||||
message_count: string;
|
|
||||||
}>(
|
|
||||||
`SELECT
|
|
||||||
r.id, r.room_name, r.public as is_public,
|
|
||||||
r.project as project_id,
|
|
||||||
p.name as project_name,
|
|
||||||
COALESCE(w.name, '') as workspace_name,
|
|
||||||
r.created_by::text as created_by,
|
|
||||||
r.created_at::text as created_at,
|
|
||||||
r.last_msg_at::text as last_msg_at,
|
|
||||||
COUNT(DISTINCT rm.user)::text as member_count,
|
|
||||||
(SELECT COUNT(*) FROM room_message WHERE room = r.id AND revoked IS NULL)::text as message_count
|
|
||||||
FROM room r
|
|
||||||
JOIN project p ON p.id = r.project
|
|
||||||
LEFT JOIN workspace w ON w.id = p.workspace_id
|
|
||||||
LEFT JOIN room_member rm ON rm.room = r.id
|
|
||||||
WHERE ${whereCond}
|
|
||||||
GROUP BY r.id, p.name, w.name
|
|
||||||
ORDER BY r.last_msg_at DESC
|
|
||||||
LIMIT $${limitIdx} OFFSET $${offsetIdx}`,
|
|
||||||
[...params, pageSize, offset]
|
|
||||||
),
|
|
||||||
query<{ count: string }>(
|
|
||||||
`SELECT COUNT(DISTINCT r.id)::text as count
|
|
||||||
FROM room r
|
|
||||||
JOIN project p ON p.id = r.project
|
|
||||||
WHERE ${whereCond}`,
|
|
||||||
params
|
|
||||||
),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const rooms = roomsResult.rows.map((r) => ({
|
|
||||||
id: r.id,
|
|
||||||
roomName: r.room_name,
|
|
||||||
isPublic: r.is_public,
|
|
||||||
projectId: r.project_id,
|
|
||||||
projectName: r.project_name,
|
|
||||||
workspaceName: r.workspace_name,
|
|
||||||
createdBy: r.created_by,
|
|
||||||
createdAt: String(r.created_at),
|
|
||||||
lastMsgAt: String(r.last_msg_at),
|
|
||||||
memberCount: parseInt(r.member_count || "0", 10),
|
|
||||||
messageCount: parseInt(r.message_count || "0", 10),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const total = parseInt(countResult.rows[0]?.count || "0", 10);
|
|
||||||
|
|
||||||
return NextResponse.json({ rooms, total, page, pageSize });
|
|
||||||
} catch (e) {
|
|
||||||
logError("Rooms error:", e);
|
|
||||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,58 +0,0 @@
|
|||||||
import { logError } from "@/lib/logger";
|
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
|
||||||
import {
|
|
||||||
getOnlinePlatformSessions,
|
|
||||||
kickPlatformSession,
|
|
||||||
PlatformSessionInfo,
|
|
||||||
} from "@/lib/redis";
|
|
||||||
import { createAuditLog } from "@/lib/log";
|
|
||||||
|
|
||||||
function getAuthInfo(req: NextRequest) {
|
|
||||||
return {
|
|
||||||
userId: parseInt(req.headers.get("x-admin-user-id") || "0", 10),
|
|
||||||
username: req.headers.get("x-admin-username") || "unknown",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
try {
|
|
||||||
const sessions = await getOnlinePlatformSessions();
|
|
||||||
// Log for debugging if sessions are empty
|
|
||||||
if (sessions.length === 0) {
|
|
||||||
console.log("[platform-sessions] No sessions found via Redis SCAN session:user_uid:*");
|
|
||||||
}
|
|
||||||
return NextResponse.json({ sessions });
|
|
||||||
} catch (e) {
|
|
||||||
logError("Get platform sessions error:", e);
|
|
||||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function DELETE(req: NextRequest) {
|
|
||||||
try {
|
|
||||||
const body = await req.json() as { sessionId?: string };
|
|
||||||
const { sessionId } = body;
|
|
||||||
|
|
||||||
if (!sessionId) {
|
|
||||||
return NextResponse.json({ error: "sessionId 不能为空" }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const ok = await kickPlatformSession(sessionId);
|
|
||||||
|
|
||||||
const { userId, username } = getAuthInfo(req);
|
|
||||||
await createAuditLog({
|
|
||||||
userId,
|
|
||||||
username,
|
|
||||||
action: "delete",
|
|
||||||
resource: "platform_session",
|
|
||||||
resourceId: sessionId.slice(0, 8) + "...",
|
|
||||||
ipAddress: req.headers.get("x-forwarded-for") || undefined,
|
|
||||||
userAgent: req.headers.get("user-agent") || undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
return NextResponse.json({ success: ok });
|
|
||||||
} catch (e) {
|
|
||||||
logError("Delete platform session error:", e);
|
|
||||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,80 +0,0 @@
|
|||||||
import { logError } from "@/lib/logger";
|
|
||||||
import { NextResponse } from "next/server";
|
|
||||||
import { query } from "@/lib/db";
|
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
try {
|
|
||||||
const [
|
|
||||||
userCount, workspaceCount, projectCount, roomCount,
|
|
||||||
recentUsers, recentWorkspaces,
|
|
||||||
planDist, recentProjects,
|
|
||||||
] = await Promise.all([
|
|
||||||
query<{ count: string }>("SELECT COUNT(*) as count FROM \"user\""),
|
|
||||||
query<{ count: string }>("SELECT COUNT(*) as count FROM workspace WHERE deleted_at IS NULL"),
|
|
||||||
query<{ count: string }>("SELECT COUNT(*) as count FROM project"),
|
|
||||||
query<{ count: string }>("SELECT COUNT(*) as count FROM room"),
|
|
||||||
query<{ count: string }>(
|
|
||||||
`SELECT uid, username, display_name, last_sign_in_at, created_at
|
|
||||||
FROM "user" ORDER BY created_at DESC LIMIT 10`
|
|
||||||
),
|
|
||||||
query<{ id: string; name: string; slug: string; plan: string; member_count: string }>(
|
|
||||||
`SELECT w.id, w.name, w.slug, w.plan,
|
|
||||||
COUNT(DISTINCT wm.user_id)::text as member_count
|
|
||||||
FROM workspace w
|
|
||||||
LEFT JOIN workspace_membership wm ON wm.workspace_id = w.id
|
|
||||||
WHERE w.deleted_at IS NULL
|
|
||||||
GROUP BY w.id
|
|
||||||
ORDER BY w.created_at DESC LIMIT 10`
|
|
||||||
),
|
|
||||||
query<{ plan: string; count: string }>(
|
|
||||||
`SELECT COALESCE(plan, 'unknown') as plan, COUNT(*)::text as count
|
|
||||||
FROM workspace WHERE deleted_at IS NULL GROUP BY plan`
|
|
||||||
),
|
|
||||||
query<{ id: string; name: string; workspace_name: string | null; member_count: string }>(
|
|
||||||
`SELECT p.id, p.name, w.name as workspace_name,
|
|
||||||
COUNT(DISTINCT pm.user_uuid)::text as member_count
|
|
||||||
FROM project p
|
|
||||||
LEFT JOIN workspace w ON w.id = p.workspace_id
|
|
||||||
LEFT JOIN project_members pm ON pm.project_uuid = p.id
|
|
||||||
WHERE 1=1
|
|
||||||
GROUP BY p.id, w.name
|
|
||||||
ORDER BY p.created_at DESC LIMIT 10`
|
|
||||||
),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const stats = {
|
|
||||||
userCount: parseInt(userCount.rows[0]?.count || "0", 10),
|
|
||||||
workspaceCount: parseInt(workspaceCount.rows[0]?.count || "0", 10),
|
|
||||||
projectCount: parseInt(projectCount.rows[0]?.count || "0", 10),
|
|
||||||
roomCount: parseInt(roomCount.rows[0]?.count || "0", 10),
|
|
||||||
};
|
|
||||||
|
|
||||||
const planDistribution = planDist.rows.map((p) => ({
|
|
||||||
plan: p.plan,
|
|
||||||
count: parseInt(p.count, 10),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const recentWorkspacesWithMembers = recentWorkspaces.rows.map((w) => ({
|
|
||||||
id: w.id, name: w.name, slug: w.slug, plan: w.plan,
|
|
||||||
memberCount: parseInt(w.member_count || "0", 10),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const recentProjectsWithMembers = recentProjects.rows.map((p) => ({
|
|
||||||
id: p.id, name: p.name, workspaceName: p.workspace_name,
|
|
||||||
memberCount: parseInt(p.member_count || "0", 10),
|
|
||||||
}));
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
stats,
|
|
||||||
recentUsers: recentUsers.rows,
|
|
||||||
recentWorkspaces: recentWorkspacesWithMembers,
|
|
||||||
recentProjects: recentProjectsWithMembers,
|
|
||||||
planDistribution,
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
logError("Stats error:", e);
|
|
||||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,152 +0,0 @@
|
|||||||
import { logError } from "@/lib/logger";
|
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
|
||||||
import { query } from "@/lib/db";
|
|
||||||
import { createAuditLog } from "@/lib/log";
|
|
||||||
import argon2 from "argon2";
|
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
|
||||||
|
|
||||||
export async function GET(
|
|
||||||
req: NextRequest,
|
|
||||||
{ params }: { params: Promise<{ uid: string }> }
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const { uid } = await params;
|
|
||||||
|
|
||||||
const result = await query(
|
|
||||||
`SELECT u.uid, u.username, u.display_name, u.avatar_url,
|
|
||||||
u.website_url, u.organization,
|
|
||||||
u.last_sign_in_at::text, u.created_at::text,
|
|
||||||
up.is_active,
|
|
||||||
ue.email
|
|
||||||
FROM "user" u
|
|
||||||
LEFT JOIN user_password up ON up.user = u.uid
|
|
||||||
LEFT JOIN user_email ue ON ue.user = u.uid
|
|
||||||
WHERE u.uid = $1`,
|
|
||||||
[uid]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!result.rows.length) {
|
|
||||||
return NextResponse.json({ error: "用户不存在" }, { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const r = result.rows[0];
|
|
||||||
return NextResponse.json({
|
|
||||||
user: {
|
|
||||||
uid: r.uid,
|
|
||||||
username: r.username,
|
|
||||||
displayName: r.display_name,
|
|
||||||
avatarUrl: r.avatar_url,
|
|
||||||
websiteUrl: r.website_url,
|
|
||||||
organization: r.organization,
|
|
||||||
lastSignInAt: r.last_sign_in_at,
|
|
||||||
createdAt: r.created_at,
|
|
||||||
isActive: r.is_active ?? true,
|
|
||||||
email: r.email,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
logError("Get user error:", e);
|
|
||||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function PATCH(
|
|
||||||
req: NextRequest,
|
|
||||||
{ params }: { params: Promise<{ uid: string }> }
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const { uid } = await params;
|
|
||||||
const body = await req.json() as {
|
|
||||||
email?: string;
|
|
||||||
password?: string;
|
|
||||||
displayName?: string;
|
|
||||||
organization?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const adminUserId = parseInt(req.headers.get("x-admin-user-id") || "0", 10);
|
|
||||||
const adminUsername = req.headers.get("x-admin-username") || "unknown";
|
|
||||||
|
|
||||||
// 检查用户是否存在
|
|
||||||
const exist = await query<{ uid: string }>(`SELECT uid FROM "user" WHERE uid = $1`, [uid]);
|
|
||||||
if (!exist.rows.length) {
|
|
||||||
return NextResponse.json({ error: "用户不存在" }, { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// 修改邮箱
|
|
||||||
if (body.email !== undefined) {
|
|
||||||
const emailUpsert = `
|
|
||||||
INSERT INTO user_email (user, email)
|
|
||||||
VALUES ($1, $2)
|
|
||||||
ON CONFLICT (user) DO UPDATE SET email = EXCLUDED.email`;
|
|
||||||
await query(emailUpsert, [uid, body.email]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 修改密码:复用现有盐值重新加密
|
|
||||||
if (body.password) {
|
|
||||||
if (body.password.length < 6) {
|
|
||||||
return NextResponse.json({ error: "密码长度至少6位" }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// 读取现有盐值
|
|
||||||
const pwRow = await query<{ password_salt: string | null }>(
|
|
||||||
`SELECT password_salt FROM user_password WHERE user = $1`,
|
|
||||||
[uid]
|
|
||||||
);
|
|
||||||
|
|
||||||
let hash: string;
|
|
||||||
if (pwRow.rows[0]?.password_salt) {
|
|
||||||
// 复用现有盐值,重新加密
|
|
||||||
hash = await argon2.hash(body.password, {
|
|
||||||
salt: Buffer.from(pwRow.rows[0].password_salt!, "base64"),
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// 无现有记录,生成新盐并加密
|
|
||||||
hash = await argon2.hash(body.password);
|
|
||||||
}
|
|
||||||
|
|
||||||
// upsert user_password(保留现有 is_active 状态)
|
|
||||||
const upsert = `
|
|
||||||
INSERT INTO user_password (user, password_hash, is_active)
|
|
||||||
VALUES ($1, $2, true)
|
|
||||||
ON CONFLICT (user) DO UPDATE SET password_hash = EXCLUDED.password_hash`;
|
|
||||||
await query(upsert, [uid, hash]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 修改 display_name / organization
|
|
||||||
const updates: string[] = [];
|
|
||||||
const up: unknown[] = [];
|
|
||||||
let idx = 1;
|
|
||||||
if (body.displayName !== undefined) {
|
|
||||||
updates.push(`display_name = $${idx++}`);
|
|
||||||
up.push(body.displayName || null);
|
|
||||||
}
|
|
||||||
if (body.organization !== undefined) {
|
|
||||||
updates.push(`organization = $${idx++}`);
|
|
||||||
up.push(body.organization || null);
|
|
||||||
}
|
|
||||||
if (updates.length > 0) {
|
|
||||||
up.push(uid);
|
|
||||||
await query(
|
|
||||||
`UPDATE "user" SET ${updates.join(", ")}, updated_at = NOW() WHERE uid = $${idx}`,
|
|
||||||
up
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await createAuditLog({
|
|
||||||
userId: adminUserId,
|
|
||||||
username: adminUsername,
|
|
||||||
action: "update",
|
|
||||||
resource: "platform_user",
|
|
||||||
resourceId: uid,
|
|
||||||
requestParams: { email: !!body.email, password: !!body.password, displayName: !!body.displayName },
|
|
||||||
ipAddress: req.headers.get("x-forwarded-for") || undefined,
|
|
||||||
userAgent: req.headers.get("user-agent") || undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
return NextResponse.json({ success: true });
|
|
||||||
} catch (e) {
|
|
||||||
logError("Update user error:", e);
|
|
||||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,117 +0,0 @@
|
|||||||
import { logError } from "@/lib/logger";
|
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
|
||||||
import { query } from "@/lib/db";
|
|
||||||
import { createAuditLog } from "@/lib/log";
|
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
|
||||||
try {
|
|
||||||
const { searchParams } = req.nextUrl;
|
|
||||||
const page = Math.max(1, parseInt(searchParams.get("page") || "1", 10));
|
|
||||||
const pageSize = Math.max(1, parseInt(searchParams.get("pageSize") || "20", 10));
|
|
||||||
const search = searchParams.get("search") || "";
|
|
||||||
const offset = (page - 1) * pageSize;
|
|
||||||
|
|
||||||
let whereClause = "";
|
|
||||||
const params: unknown[] = [];
|
|
||||||
if (search) {
|
|
||||||
whereClause = 'WHERE username ILIKE $1 OR display_name ILIKE $1';
|
|
||||||
params.push(`%${search}%`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const countResult = await query<{ count: string }>(
|
|
||||||
`SELECT COUNT(*) FROM "user" ${whereClause}`,
|
|
||||||
params
|
|
||||||
);
|
|
||||||
const total = parseInt(countResult.rows[0].count, 10);
|
|
||||||
|
|
||||||
const result = await query(
|
|
||||||
`SELECT u.uid, u.username, u.display_name, u.avatar_url, u.organization,
|
|
||||||
u.last_sign_in_at::text as last_sign_in_at, u.created_at::text as created_at,
|
|
||||||
COALESCE(up.is_active, true) as is_active,
|
|
||||||
ue.email
|
|
||||||
FROM "user" u
|
|
||||||
LEFT JOIN user_password up ON up.user = u.uid
|
|
||||||
LEFT JOIN user_email ue ON ue.user = u.uid
|
|
||||||
${whereClause}
|
|
||||||
ORDER BY u.created_at DESC
|
|
||||||
LIMIT $1 OFFSET $2`,
|
|
||||||
params.length > 0
|
|
||||||
? [...params, pageSize, offset]
|
|
||||||
: [pageSize, offset]
|
|
||||||
);
|
|
||||||
|
|
||||||
const users = result.rows.map((r: Record<string, unknown>) => ({
|
|
||||||
uid: r.uid,
|
|
||||||
username: r.username,
|
|
||||||
display_name: r.display_name,
|
|
||||||
avatar_url: r.avatar_url,
|
|
||||||
organization: r.organization,
|
|
||||||
last_sign_in_at: r.last_sign_in_at,
|
|
||||||
created_at: r.created_at,
|
|
||||||
is_active: r.is_active,
|
|
||||||
email: r.email,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return NextResponse.json({ users, total, page, pageSize });
|
|
||||||
} catch (e) {
|
|
||||||
logError("Platform users error:", e);
|
|
||||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function PATCH(req: NextRequest) {
|
|
||||||
try {
|
|
||||||
const body = await req.json() as {
|
|
||||||
ids?: string[];
|
|
||||||
action?: "enable" | "disable";
|
|
||||||
};
|
|
||||||
const { ids = [], action } = body;
|
|
||||||
|
|
||||||
if (!ids.length) {
|
|
||||||
return NextResponse.json({ error: "至少选择一个用户" }, { status: 400 });
|
|
||||||
}
|
|
||||||
if (!action || !["enable", "disable"].includes(action)) {
|
|
||||||
return NextResponse.json({ error: "action 必须是 enable 或 disable" }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const isActive = action === "enable";
|
|
||||||
|
|
||||||
// Get user ids from uids
|
|
||||||
const uidPlaceholders = ids.map((_, i) => `$${i + 1}`).join(", ");
|
|
||||||
const uidResult = await query<{ uid: string }>(
|
|
||||||
`SELECT uid FROM "user" WHERE uid IN (${uidPlaceholders})`,
|
|
||||||
ids
|
|
||||||
);
|
|
||||||
const uids = uidResult.rows.map((r) => r.uid);
|
|
||||||
|
|
||||||
if (!uids.length) {
|
|
||||||
return NextResponse.json({ error: "未找到匹配的用户" }, { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const uidPlaceholders2 = uids.map((_, i) => `$${i + 1}`).join(", ");
|
|
||||||
await query(
|
|
||||||
`UPDATE user_password SET is_active = $${uids.length + 1}, updated_at = NOW() WHERE "user" IN (${uidPlaceholders2})`,
|
|
||||||
[...uids, isActive]
|
|
||||||
);
|
|
||||||
|
|
||||||
const adminUserId = parseInt(req.headers.get("x-admin-user-id") || "0", 10);
|
|
||||||
const adminUsername = req.headers.get("x-admin-username") || "unknown";
|
|
||||||
await createAuditLog({
|
|
||||||
userId: adminUserId,
|
|
||||||
username: adminUsername,
|
|
||||||
action: "update",
|
|
||||||
resource: "user_batch_status",
|
|
||||||
resourceId: `batch(${uids.length})`,
|
|
||||||
requestParams: { uidCount: ids.length, userIdCount: uids.length, action },
|
|
||||||
ipAddress: req.headers.get("x-forwarded-for") || undefined,
|
|
||||||
userAgent: req.headers.get("user-agent") || undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
return NextResponse.json({ success: true, updated: uids.length });
|
|
||||||
} catch (e) {
|
|
||||||
logError("Batch update user status error:", e);
|
|
||||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,101 +0,0 @@
|
|||||||
import { logError } from "@/lib/logger";
|
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
|
||||||
import { query, getClient } from "@/lib/db";
|
|
||||||
import { createAuditLog } from "@/lib/log";
|
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
|
||||||
|
|
||||||
export async function POST(
|
|
||||||
req: NextRequest,
|
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const { id } = await params;
|
|
||||||
const body = await req.json() as {
|
|
||||||
amount?: number;
|
|
||||||
description?: string;
|
|
||||||
};
|
|
||||||
const { amount = 0, description = "" } = body;
|
|
||||||
|
|
||||||
if (!amount || amount <= 0) {
|
|
||||||
return NextResponse.json({ error: "金额必须大于 0" }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get workspace info first
|
|
||||||
const wsResult = await query(
|
|
||||||
`SELECT id, slug, name FROM workspace WHERE id = $1 AND deleted_at IS NULL`,
|
|
||||||
[id]
|
|
||||||
);
|
|
||||||
if (!wsResult.rows.length) {
|
|
||||||
return NextResponse.json({ error: "Workspace 不存在" }, { status: 404 });
|
|
||||||
}
|
|
||||||
const workspace = wsResult.rows[0] as { id: string; slug: string; name: string };
|
|
||||||
|
|
||||||
// Get billing account
|
|
||||||
const billingResult = await query(
|
|
||||||
`SELECT id, balance, currency FROM workspace_billing WHERE workspace_id = $1`,
|
|
||||||
[id]
|
|
||||||
);
|
|
||||||
|
|
||||||
let currency = "USD";
|
|
||||||
if (billingResult.rows.length) {
|
|
||||||
currency = (billingResult.rows[0].currency as string) || "USD";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use transaction for atomicity
|
|
||||||
const client = await getClient();
|
|
||||||
try {
|
|
||||||
await client.query("BEGIN");
|
|
||||||
|
|
||||||
if (billingResult.rows.length) {
|
|
||||||
// Update existing balance
|
|
||||||
await client.query(
|
|
||||||
`UPDATE workspace_billing
|
|
||||||
SET balance = balance + $1, currency = $2, updated_at = NOW()
|
|
||||||
WHERE workspace_id = $3`,
|
|
||||||
[amount, currency, id]
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
// Create billing record if doesn't exist
|
|
||||||
await client.query(
|
|
||||||
`INSERT INTO workspace_billing (workspace_id, balance, currency, monthly_quota, total_spent, updated_at, created_at)
|
|
||||||
VALUES ($1, $2, $3, 0, 0, NOW(), NOW())`,
|
|
||||||
[id, amount, currency]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Insert billing history
|
|
||||||
await client.query(
|
|
||||||
`INSERT INTO workspace_billing_history
|
|
||||||
(workspace_id, user_id, amount, reason, extra, currency)
|
|
||||||
VALUES ($1, NULL, $2, 'admin_credit', $3, $4)`,
|
|
||||||
[id, amount, JSON.stringify({ description: description || "Admin 手动充值" }), currency]
|
|
||||||
);
|
|
||||||
|
|
||||||
await client.query("COMMIT");
|
|
||||||
} catch (innerError) {
|
|
||||||
await client.query("ROLLBACK");
|
|
||||||
throw innerError;
|
|
||||||
} finally {
|
|
||||||
client.release();
|
|
||||||
}
|
|
||||||
|
|
||||||
const adminUserId = parseInt(req.headers.get("x-admin-user-id") || "0", 10);
|
|
||||||
const adminUsername = req.headers.get("x-admin-username") || "unknown";
|
|
||||||
await createAuditLog({
|
|
||||||
userId: adminUserId,
|
|
||||||
username: adminUsername,
|
|
||||||
action: "update",
|
|
||||||
resource: "workspace_billing",
|
|
||||||
resourceId: id,
|
|
||||||
requestParams: { workspaceSlug: workspace.slug, amount, description },
|
|
||||||
ipAddress: req.headers.get("x-forwarded-for") || undefined,
|
|
||||||
userAgent: req.headers.get("user-agent") || undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
return NextResponse.json({ success: true, amount, currency });
|
|
||||||
} catch (e) {
|
|
||||||
logError("Add workspace credit error:", e);
|
|
||||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,94 +0,0 @@
|
|||||||
import { logError } from "@/lib/logger";
|
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
|
||||||
import { query, getClient } from "@/lib/db";
|
|
||||||
import { getAdminUserId } from "@/lib/auth";
|
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
|
||||||
|
|
||||||
interface AlertConfig {
|
|
||||||
id: number;
|
|
||||||
workspace_id: string;
|
|
||||||
alert_type: string;
|
|
||||||
threshold: string;
|
|
||||||
email_enabled: boolean;
|
|
||||||
enabled: boolean;
|
|
||||||
created_at: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ALERT_TYPE_LABELS: Record<string, string> = {
|
|
||||||
low_balance: "余额不足",
|
|
||||||
monthly_quota: "月度配额",
|
|
||||||
usage_surge: "使用量激增",
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function GET(
|
|
||||||
req: NextRequest,
|
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const { id } = await params;
|
|
||||||
const result = await query<AlertConfig>(
|
|
||||||
`SELECT id, workspace_id, alert_type, threshold, email_enabled, enabled, created_at
|
|
||||||
FROM workspace_alert_config
|
|
||||||
WHERE workspace_id = $1
|
|
||||||
ORDER BY alert_type ASC`,
|
|
||||||
[id]
|
|
||||||
);
|
|
||||||
return NextResponse.json({ configs: result.rows });
|
|
||||||
} catch (e) {
|
|
||||||
logError("Alert config error:", e);
|
|
||||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function PUT(
|
|
||||||
req: NextRequest,
|
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const adminUserId = getAdminUserId(req);
|
|
||||||
if (!adminUserId) return NextResponse.json({ error: "未登录" }, { status: 401 });
|
|
||||||
|
|
||||||
const { id: workspaceId } = await params;
|
|
||||||
const body = await req.json();
|
|
||||||
const { configs } = body as {
|
|
||||||
configs: Array<{
|
|
||||||
alert_type: string;
|
|
||||||
threshold: number;
|
|
||||||
email_enabled: boolean;
|
|
||||||
enabled: boolean;
|
|
||||||
}>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const client = await getClient();
|
|
||||||
try {
|
|
||||||
await client.query("BEGIN");
|
|
||||||
|
|
||||||
for (const cfg of configs) {
|
|
||||||
await client.query(
|
|
||||||
`INSERT INTO workspace_alert_config
|
|
||||||
(workspace_id, alert_type, threshold, email_enabled, enabled, created_by)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6)
|
|
||||||
ON CONFLICT (workspace_id, alert_type)
|
|
||||||
DO UPDATE SET
|
|
||||||
threshold = EXCLUDED.threshold,
|
|
||||||
email_enabled = EXCLUDED.email_enabled,
|
|
||||||
enabled = EXCLUDED.enabled,
|
|
||||||
updated_at = NOW()`,
|
|
||||||
[workspaceId, cfg.alert_type, cfg.threshold, cfg.email_enabled, cfg.enabled, adminUserId]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await client.query("COMMIT");
|
|
||||||
return NextResponse.json({ success: true });
|
|
||||||
} catch {
|
|
||||||
await client.query("ROLLBACK");
|
|
||||||
throw "transaction error";
|
|
||||||
} finally {
|
|
||||||
client.release();
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
logError("Alert config update error:", e);
|
|
||||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,101 +0,0 @@
|
|||||||
import { logError } from "@/lib/logger";
|
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
|
||||||
import { query } from "@/lib/db";
|
|
||||||
import { createAuditLog } from "@/lib/log";
|
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
|
||||||
|
|
||||||
// PATCH /api/platform/workspaces/[id]/members/[memberId]
|
|
||||||
export async function PATCH(
|
|
||||||
req: NextRequest,
|
|
||||||
{ params }: { params: Promise<{ id: string; memberId: string }> }
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const { id, memberId } = await params;
|
|
||||||
const body = await req.json() as { role?: string };
|
|
||||||
|
|
||||||
const adminUserId = parseInt(req.headers.get("x-admin-user-id") || "0", 10);
|
|
||||||
const adminUsername = req.headers.get("x-admin-username") || "unknown";
|
|
||||||
|
|
||||||
if (body.role && !["owner", "admin", "member"].includes(body.role)) {
|
|
||||||
return NextResponse.json({ error: "无效的角色" }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// 禁止降级 owner
|
|
||||||
const member = await query<{ id: string; role: string; user_id: string }>(
|
|
||||||
`SELECT id, role, user_id FROM workspace_membership WHERE id = $1 AND workspace_id = $2`,
|
|
||||||
[memberId, id]
|
|
||||||
);
|
|
||||||
if (!member.rows.length) {
|
|
||||||
return NextResponse.json({ error: "成员不存在" }, { status: 404 });
|
|
||||||
}
|
|
||||||
if (member.rows[0].role === "owner") {
|
|
||||||
return NextResponse.json({ error: "无法修改 Owner 的角色" }, { status: 403 });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (body.role) {
|
|
||||||
await query(
|
|
||||||
`UPDATE workspace_membership SET role = $1 WHERE id = $2`,
|
|
||||||
[body.role, memberId]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await createAuditLog({
|
|
||||||
userId: adminUserId,
|
|
||||||
username: adminUsername,
|
|
||||||
action: "update",
|
|
||||||
resource: "workspace_member",
|
|
||||||
resourceId: memberId,
|
|
||||||
requestParams: { workspaceId: id, role: body.role },
|
|
||||||
ipAddress: req.headers.get("x-forwarded-for") || undefined,
|
|
||||||
userAgent: req.headers.get("user-agent") || undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
return NextResponse.json({ success: true });
|
|
||||||
} catch (e) {
|
|
||||||
logError("Update workspace member error:", e);
|
|
||||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// DELETE /api/platform/workspaces/[id]/members/[memberId]
|
|
||||||
export async function DELETE(
|
|
||||||
req: NextRequest,
|
|
||||||
{ params }: { params: Promise<{ id: string; memberId: string }> }
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const { id, memberId } = await params;
|
|
||||||
|
|
||||||
const adminUserId = parseInt(req.headers.get("x-admin-user-id") || "0", 10);
|
|
||||||
const adminUsername = req.headers.get("x-admin-username") || "unknown";
|
|
||||||
|
|
||||||
const member = await query<{ id: string; role: string; user_id: string }>(
|
|
||||||
`SELECT id, role, user_id FROM workspace_membership WHERE id = $1 AND workspace_id = $2`,
|
|
||||||
[memberId, id]
|
|
||||||
);
|
|
||||||
if (!member.rows.length) {
|
|
||||||
return NextResponse.json({ error: "成员不存在" }, { status: 404 });
|
|
||||||
}
|
|
||||||
if (member.rows[0].role === "owner") {
|
|
||||||
return NextResponse.json({ error: "无法删除 Owner" }, { status: 403 });
|
|
||||||
}
|
|
||||||
|
|
||||||
await query(`DELETE FROM workspace_membership WHERE id = $1`, [memberId]);
|
|
||||||
|
|
||||||
await createAuditLog({
|
|
||||||
userId: adminUserId,
|
|
||||||
username: adminUsername,
|
|
||||||
action: "delete",
|
|
||||||
resource: "workspace_member",
|
|
||||||
resourceId: memberId,
|
|
||||||
requestParams: { workspaceId: id, userId: member.rows[0].user_id },
|
|
||||||
ipAddress: req.headers.get("x-forwarded-for") || undefined,
|
|
||||||
userAgent: req.headers.get("user-agent") || undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
return NextResponse.json({ success: true });
|
|
||||||
} catch (e) {
|
|
||||||
logError("Delete workspace member error:", e);
|
|
||||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,120 +0,0 @@
|
|||||||
import { logError } from "@/lib/logger";
|
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
|
||||||
import { query } from "@/lib/db";
|
|
||||||
import { createAuditLog } from "@/lib/log";
|
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
|
||||||
|
|
||||||
// GET /api/platform/workspaces/[id]/members
|
|
||||||
export async function GET(
|
|
||||||
req: NextRequest,
|
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const { id } = await params;
|
|
||||||
|
|
||||||
const result = await query(
|
|
||||||
`SELECT wm.id, wm.workspace_id, wm.user_id, wm.role, wm.status,
|
|
||||||
wm.invited_by, wm.joined_at::text, wm.invite_token, wm.invite_expires_at::text,
|
|
||||||
u.username, u.display_name, u.avatar_url,
|
|
||||||
COALESCE(up.is_active, true) as user_is_active
|
|
||||||
FROM workspace_membership wm
|
|
||||||
JOIN "user" u ON u.uid = wm.user_id
|
|
||||||
LEFT JOIN user_password up ON up.user = u.uid
|
|
||||||
WHERE wm.workspace_id = $1
|
|
||||||
ORDER BY wm.role = 'owner' DESC, wm.role = 'admin' DESC, wm.joined_at ASC`,
|
|
||||||
[id]
|
|
||||||
);
|
|
||||||
|
|
||||||
const members = result.rows.map((r: Record<string, unknown>) => ({
|
|
||||||
id: r.id,
|
|
||||||
workspaceId: r.workspace_id,
|
|
||||||
userId: r.user_id,
|
|
||||||
role: r.role,
|
|
||||||
status: r.status,
|
|
||||||
invitedBy: r.invited_by,
|
|
||||||
joinedAt: r.joined_at,
|
|
||||||
inviteToken: r.invite_token,
|
|
||||||
inviteExpiresAt: r.invite_expires_at,
|
|
||||||
username: r.username,
|
|
||||||
displayName: r.display_name,
|
|
||||||
avatarUrl: r.avatar_url,
|
|
||||||
userIsActive: r.user_is_active,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return NextResponse.json({ members });
|
|
||||||
} catch (e) {
|
|
||||||
logError("List workspace members error:", e);
|
|
||||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// POST /api/platform/workspaces/[id]/members — 添加成员
|
|
||||||
export async function POST(
|
|
||||||
req: NextRequest,
|
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const { id } = await params;
|
|
||||||
const body = await req.json() as {
|
|
||||||
userId?: string;
|
|
||||||
role?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!body.userId) {
|
|
||||||
return NextResponse.json({ error: "缺少 userId" }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const adminUserId = parseInt(req.headers.get("x-admin-user-id") || "0", 10);
|
|
||||||
const adminUsername = req.headers.get("x-admin-username") || "unknown";
|
|
||||||
|
|
||||||
const role = body.role || "member";
|
|
||||||
if (!["owner", "admin", "member"].includes(role)) {
|
|
||||||
return NextResponse.json({ error: "无效的角色" }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查 workspace 是否存在
|
|
||||||
const wsRow = await query<{ id: string }>(`SELECT id FROM workspace WHERE id = $1 AND deleted_at IS NULL`, [id]);
|
|
||||||
if (!wsRow.rows.length) {
|
|
||||||
return NextResponse.json({ error: "Workspace 不存在" }, { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查用户是否存在
|
|
||||||
const userRow = await query<{ uid: string }>(`SELECT uid FROM "user" WHERE uid = $1`, [body.userId]);
|
|
||||||
if (!userRow.rows.length) {
|
|
||||||
return NextResponse.json({ error: "用户不存在" }, { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查是否已是成员
|
|
||||||
const exist = await query(
|
|
||||||
`SELECT id FROM workspace_membership WHERE workspace_id = $1 AND user_id = $2`,
|
|
||||||
[id, body.userId]
|
|
||||||
);
|
|
||||||
if (exist.rows.length) {
|
|
||||||
return NextResponse.json({ error: "该用户已是成员" }, { status: 409 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await query(
|
|
||||||
`INSERT INTO workspace_membership (workspace_id, user_id, role, status, joined_at)
|
|
||||||
VALUES ($1, $2, $3, 'active', NOW())
|
|
||||||
RETURNING id`,
|
|
||||||
[id, body.userId, role]
|
|
||||||
);
|
|
||||||
|
|
||||||
await createAuditLog({
|
|
||||||
userId: adminUserId,
|
|
||||||
username: adminUsername,
|
|
||||||
action: "create",
|
|
||||||
resource: "workspace_member",
|
|
||||||
resourceId: String(result.rows[0]?.id),
|
|
||||||
requestParams: { workspaceId: id, userId: body.userId, role },
|
|
||||||
ipAddress: req.headers.get("x-forwarded-for") || undefined,
|
|
||||||
userAgent: req.headers.get("user-agent") || undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
return NextResponse.json({ success: true, id: result.rows[0]?.id }, { status: 201 });
|
|
||||||
} catch (e) {
|
|
||||||
logError("Add workspace member error:", e);
|
|
||||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,66 +0,0 @@
|
|||||||
import { logError } from "@/lib/logger";
|
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
|
||||||
import { query } from "@/lib/db";
|
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
|
||||||
|
|
||||||
export async function GET(
|
|
||||||
_req: NextRequest,
|
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const { id } = await params;
|
|
||||||
|
|
||||||
const [workspace, members, projects, billing] = await Promise.all([
|
|
||||||
query(
|
|
||||||
`SELECT w.*, wb.balance, wb.currency, wb.monthly_quota, wb.total_spent
|
|
||||||
FROM workspace w
|
|
||||||
LEFT JOIN workspace_billing wb ON wb.workspace_id = w.id
|
|
||||||
WHERE w.id = $1 AND w.deleted_at IS NULL`,
|
|
||||||
[id]
|
|
||||||
),
|
|
||||||
query(
|
|
||||||
`SELECT wm.id, u.uid, u.username, u.display_name, u.avatar_url,
|
|
||||||
wm.role, wm.status, wm.joined_at
|
|
||||||
FROM workspace_membership wm
|
|
||||||
JOIN "user" u ON u.uid = wm.user_id
|
|
||||||
WHERE wm.workspace_id = $1
|
|
||||||
ORDER BY wm.role = 'owner' DESC, wm.role = 'admin' DESC, wm.joined_at ASC`,
|
|
||||||
[id]
|
|
||||||
),
|
|
||||||
query(
|
|
||||||
`SELECT p.id, p.name, p.is_public, p.created_at,
|
|
||||||
COUNT(DISTINCT pm.user_uuid)::int as member_count
|
|
||||||
FROM project p
|
|
||||||
LEFT JOIN project_members pm ON pm.project_uuid = p.id
|
|
||||||
WHERE p.workspace_id = $1
|
|
||||||
GROUP BY p.id
|
|
||||||
ORDER BY p.created_at DESC
|
|
||||||
LIMIT 50`,
|
|
||||||
[id]
|
|
||||||
),
|
|
||||||
query(
|
|
||||||
`SELECT reason, amount, extra as description, created_at
|
|
||||||
FROM workspace_billing_history
|
|
||||||
WHERE workspace_id = $1
|
|
||||||
ORDER BY created_at DESC
|
|
||||||
LIMIT 50`,
|
|
||||||
[id]
|
|
||||||
),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (!workspace.rows.length) {
|
|
||||||
return NextResponse.json({ error: "Workspace 不存在" }, { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
workspace: workspace.rows[0],
|
|
||||||
members: members.rows,
|
|
||||||
projects: projects.rows,
|
|
||||||
billingHistory: billing.rows,
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
logError("Workspace detail error:", e);
|
|
||||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,115 +0,0 @@
|
|||||||
import { logError } from "@/lib/logger";
|
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
|
||||||
import { query } from "@/lib/db";
|
|
||||||
import { createAuditLog } from "@/lib/log";
|
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
|
||||||
try {
|
|
||||||
const { searchParams } = req.nextUrl;
|
|
||||||
const page = Math.max(1, parseInt(searchParams.get("page") || "1", 10));
|
|
||||||
const pageSize = Math.max(1, parseInt(searchParams.get("pageSize") || "20", 10));
|
|
||||||
const search = searchParams.get("search") || "";
|
|
||||||
const plan = searchParams.get("plan") || "";
|
|
||||||
const offset = (page - 1) * pageSize;
|
|
||||||
|
|
||||||
let whereClause = `WHERE w.deleted_at IS NULL`;
|
|
||||||
const params: unknown[] = [];
|
|
||||||
let idx = 1;
|
|
||||||
|
|
||||||
if (search) {
|
|
||||||
whereClause += ` AND (w.name ILIKE $${idx} OR w.slug ILIKE $${idx})`;
|
|
||||||
params.push(`%${search}%`);
|
|
||||||
idx++;
|
|
||||||
}
|
|
||||||
if (plan) {
|
|
||||||
whereClause += ` AND w.plan = $${idx}`;
|
|
||||||
params.push(plan);
|
|
||||||
idx++;
|
|
||||||
}
|
|
||||||
|
|
||||||
const countResult = await query<{ count: string }>(
|
|
||||||
`SELECT COUNT(*) FROM workspace w ${whereClause}`,
|
|
||||||
params
|
|
||||||
);
|
|
||||||
const total = parseInt(countResult.rows[0]?.count || "0", 10);
|
|
||||||
|
|
||||||
const limitParam = idx++;
|
|
||||||
const offsetParam = idx++;
|
|
||||||
const rows = await query(
|
|
||||||
`SELECT
|
|
||||||
w.id, w.slug, w.name, w.plan, w.description,
|
|
||||||
w.billing_email, w.created_at, w.updated_at,
|
|
||||||
wb.balance, wb.currency,
|
|
||||||
COUNT(DISTINCT wm.user_id)::int as member_count,
|
|
||||||
COUNT(DISTINCT p.id)::int as project_count
|
|
||||||
FROM workspace w
|
|
||||||
LEFT JOIN workspace_billing wb ON wb.workspace_id = w.id
|
|
||||||
LEFT JOIN workspace_membership wm ON wm.workspace_id = w.id
|
|
||||||
LEFT JOIN project p ON p.workspace_id = w.id
|
|
||||||
${whereClause}
|
|
||||||
GROUP BY w.id, wb.balance, wb.currency
|
|
||||||
ORDER BY w.created_at DESC
|
|
||||||
LIMIT $${limitParam} OFFSET $${offsetParam}`,
|
|
||||||
[...params, pageSize, offset]
|
|
||||||
);
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
workspaces: rows.rows,
|
|
||||||
total,
|
|
||||||
page,
|
|
||||||
pageSize,
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
logError("List workspaces error:", e);
|
|
||||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function PATCH(req: NextRequest) {
|
|
||||||
try {
|
|
||||||
const body = await req.json() as {
|
|
||||||
ids?: string[];
|
|
||||||
plan?: string;
|
|
||||||
};
|
|
||||||
const { ids = [], plan } = body;
|
|
||||||
|
|
||||||
if (!ids.length) {
|
|
||||||
return NextResponse.json({ error: "至少选择一个 Workspace" }, { status: 400 });
|
|
||||||
}
|
|
||||||
if (!plan) {
|
|
||||||
return NextResponse.json({ error: "plan 不能为空" }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const validPlans = ["free", "starter", "pro", "enterprise"];
|
|
||||||
if (!validPlans.includes(plan)) {
|
|
||||||
return NextResponse.json({ error: "无效的计划类型" }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const placeholders = ids.map((_, i) => `$${i + 1}`).join(", ");
|
|
||||||
await query(
|
|
||||||
`UPDATE workspace SET plan = $${ids.length + 1}, updated_at = NOW() WHERE id IN (${placeholders}) AND deleted_at IS NULL`,
|
|
||||||
[...ids, plan]
|
|
||||||
);
|
|
||||||
|
|
||||||
const { searchParams } = req.headers.get("referer") ? new URL(req.headers.get("referer")!) : new URL("http://localhost/");
|
|
||||||
const adminUserId = parseInt(req.headers.get("x-admin-user-id") || "0", 10);
|
|
||||||
const adminUsername = req.headers.get("x-admin-username") || "unknown";
|
|
||||||
await createAuditLog({
|
|
||||||
userId: adminUserId,
|
|
||||||
username: adminUsername,
|
|
||||||
action: "update",
|
|
||||||
resource: "workspace_batch_plan",
|
|
||||||
resourceId: `batch(${ids.length})`,
|
|
||||||
requestParams: { ids, plan },
|
|
||||||
ipAddress: req.headers.get("x-forwarded-for") || undefined,
|
|
||||||
userAgent: req.headers.get("user-agent") || undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
return NextResponse.json({ success: true, updated: ids.length });
|
|
||||||
} catch (e) {
|
|
||||||
logError("Batch update workspace plan error:", e);
|
|
||||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user