Compare commits

...

78 Commits

Author SHA1 Message Date
ZhenYi
894c3873a4 fix deploy repo volume permissions 2026-05-15 00:50:13 +08:00
ZhenYi
6ba06be47e add rum 2026-05-15 00:44:05 +08:00
ZhenYi
3a1a7b97db feat: update App routing and MeSidebar component
Add project routes to App, update MeSidebar with navigation
links to invitations and settings.
2026-05-14 23:15:54 +08:00
ZhenYi
31e9bb68ac feat(ui): update Header and ChannelSidebar components
Refine Header with improved layout, update ChannelSidebar
with channel navigation and ChatPage integration.
2026-05-14 23:15:40 +08:00
ZhenYi
c015871024 feat(channel): enhance message components
Update MessageItem with reactions and mentions, add PinPanel
for pinned messages, enhance ThreadPanel for threaded replies.
2026-05-14 23:15:26 +08:00
ZhenYi
8702312c32 feat(settings): add AccessSettings and room context updates
Add AccessSettings component for project access management,
update room context with improved state management.
2026-05-14 23:15:16 +08:00
ZhenYi
c308fc044d feat(project): enhance channel and issues pages
Update ChannelPage with message list integration, enhance
IssuesPage with drag-and-drop support, add NewIssuePage.
2026-05-14 23:14:59 +08:00
ZhenYi
f4653f2399 feat(project): update project layout and routing
Enhance project layout with header navigation, channel
sidebar integration, and improved routing structure.
2026-05-14 23:14:36 +08:00
ZhenYi
4322f36a76 feat: add project invitation and join pages
Add MyInvitationsPage, ProjectInvitationPage, and ProjectJoinPage
for handling project invitation flows.
2026-05-14 23:14:21 +08:00
ZhenYi
b737d19166 feat(api): add project-related API endpoints
Add API functions for project invitations and join operations.
2026-05-14 23:14:08 +08:00
ZhenYi
110945e438 chore: update Vite config and add channel animations CSS
Remove dev proxy, add channel animation styles for message
display effects.
2026-05-14 23:13:52 +08:00
ZhenYi
32bd760b77 chore: update package.json dependencies and index.html
Update project dependencies and enhance index.html with
meta tags and link references.
2026-05-14 21:51:05 +08:00
ZhenYi
df4cf55b07 feat: update App.tsx with project features
Add project-related feature components including issues and
project management integration.
2026-05-14 21:50:47 +08:00
ZhenYi
826fa1226a feat: enhance MePage and add layout improvements
Add activity stats display to MePage and include additional
layout enhancements for consistent page structure.
2026-05-14 21:50:31 +08:00
ZhenYi
9981664731 feat(me): add ActivityTimeline and NotificationList components
Add ActivityTimeline component with user activity display and
NotificationList for user notifications.
2026-05-14 21:50:18 +08:00
ZhenYi
e64dc94d29 feat: add IssuesPage with tabs and kanban board
Implement issues listing page with tab navigation (All/To Do/In Progress/Done)
and kanban board view using TanStack Table. Connect to API endpoints with
pagination and filtering support.
2026-05-14 21:50:04 +08:00
ZhenYi
aaf518a66c feat: add Vite configuration with aliases and proxy
Add path aliases (@/* -> src/*) and development proxy
configuration for API routing.
2026-05-14 21:49:47 +08:00
ZhenYi
f4397256bd refactor: remove unused Redux store hooks
Delete obsolete Redux store configuration files as part of
the Zustand migration. These files are no longer referenced
after the store refactor.
2026-05-14 21:49:37 +08:00
ZhenYi
a1d245a767 Enable RSA support in russh 2026-05-14 21:45:05 +08:00
ZhenYi
a3ecf0c88b Separate SSH key probe from authentication 2026-05-14 21:44:55 +08:00
ZhenYi
b8bd0ec545 refactor(frontend): apply formatting and update chat, settings, project pages 2026-05-14 10:02:54 +08:00
ZhenYi
8731c01908 feat(api,config): add ESLint rules, update OpenAPI schema with fork API and project stats 2026-05-14 10:02:44 +08:00
ZhenYi
12eaa83b87 refactor(transport): apply rustfmt formatting 2026-05-14 10:02:36 +08:00
ZhenYi
06c08148cb refactor(service,session): apply rustfmt formatting 2026-05-14 10:02:29 +08:00
ZhenYi
18ea3cc355 refactor(room): apply rustfmt formatting 2026-05-14 10:02:21 +08:00
ZhenYi
52e1831452 refactor(observability,queue): apply rustfmt formatting 2026-05-14 10:02:15 +08:00
ZhenYi
8fd6dbb68b refactor(migrate,models): apply rustfmt formatting 2026-05-14 10:02:07 +08:00
ZhenYi
4c4c33f970 refactor(git,gingress-proxy): apply rustfmt formatting 2026-05-14 10:02:00 +08:00
ZhenYi
2dcb5b3028 refactor(fctool): apply rustfmt formatting 2026-05-14 10:01:52 +08:00
ZhenYi
02a1020f75 refactor(config,db,email): apply rustfmt formatting 2026-05-14 10:01:46 +08:00
ZhenYi
724858a721 refactor(api): apply rustfmt and update fork API + project stats endpoint 2026-05-14 10:01:39 +08:00
ZhenYi
e29ef0e76d refactor(agent): apply rustfmt formatting across agent library 2026-05-14 10:01:32 +08:00
ZhenYi
b6832923fa refactor(apps): apply rustfmt formatting across all application binaries 2026-05-14 10:01:25 +08:00
ZhenYi
18b4864050 refactor(deploy): add SSH service annotations and externalTrafficPolicy support 2026-05-14 10:01:18 +08:00
ZhenYi
96ce6fde1c feat(docker): add safe.directory git config for git-enabled containers
Prevents "dubious ownership" errors when running git commands in containers
with mounted repositories (root user + external volume).
2026-05-13 00:32:41 +08:00
ZhenYi
1c55cb8559 refactor(docker): run all containers as root, add compact log format support
- Docker: remove appuser creation and USER directive in all 7 Dockerfiles
- observability: recognize APP_LOG_FORMAT=compact as non-JSON pretty output
2026-05-12 23:59:31 +08:00
ZhenYi
066bb4e83d fix(react): add resolve.dedupe for react/react-dom to prevent CJS multi-instance
React 19.2.5 is pure CJS. Rollup's CJS→ESM interop can create separate scoped
ReactSharedInternals objects for react and react-dom, causing hooks to see a null
dispatcher on the server build. resolve.dedupe forces a single shared instance.
2026-05-12 22:41:44 +08:00
ZhenYi
e185885557 fix(react): switch namespace import to default import to fix stale Vite CJS interop
- 3 files: theme-provider, carousel, sidebar
- index.html: set title to GitData.AI, favicon to /logo.png
- add public/logo.png and robots.txt
2026-05-12 22:03:54 +08:00
ZhenYi
e6ba5433e2 bucong 提交 2026-05-12 18:06:20 +08:00
ZhenYi
d19a3ca557 fix(avatar): gracefully degrade when avatar directory is not writable
AppAvatar::init() create_dir_all failure now logs a warning instead of
failing fatally. This fixes the email worker crash — it runs AppService
but has no PVC mount, so /data/avatars is not accessible. Other services
(app) have the PVC mounted and are unaffected.
2026-05-12 18:05:55 +08:00
ZhenYi
cac342bdc5 refactor(deploy): remove gingress controller, switch to nginx ingress
- Delete gingress templates (deployment, rbac, service)
- Remove gingress config block from values.yaml
- Switch ingress class to nginx with full annotations:
  - Unlimited body size for large file uploads
  - WebSocket support with 1h timeouts
  - Cookie-based session affinity
  - Real IP passthrough via X-Forwarded-For
2026-05-12 17:20:52 +08:00
ZhenYi
8ecd16868c feat(core): initialize project with access control and AI integration 2026-05-12 17:01:42 +08:00
ZhenYi
8be15cb81e fix(deploy): hardcode PVC name as shared-data in templates, remove pvcName Helm value
PVC name is now immutable — hardcoded in all 4 deployment templates instead
of being a configurable Helm value. Removed pvcName from values.yaml and
--set pvcName from deploy.sh. This ensures the PVC can never be renamed or
deleted by Helm operations, only manually via kubectl.
2026-05-12 16:36:13 +08:00
ZhenYi
dc193a061a fix(api): replace async block in Option::and_then with match for proper await 2026-05-12 16:06:32 +08:00
ZhenYi
033cfda6c5 feat: add explore page and AI elements components 2026-05-12 13:07:58 +08:00
ZhenYi
b0b33dfd9c chore(deps): update dependencies and add shadcn components.json 2026-05-12 13:07:47 +08:00
ZhenYi
d63ca39ca4 refactor(layout): update layout components, header, navigation and API client 2026-05-12 13:06:19 +08:00
ZhenYi
e86803d235 refactor(ui): update UI components, theme system and utilities 2026-05-12 13:05:28 +08:00
ZhenYi
b384f92bbf refactor(chat): update frontend chat components 2026-05-12 13:05:20 +08:00
ZhenYi
9d091d3dfb refactor(service): update observability push, chat conversation and search service 2026-05-12 13:04:46 +08:00
ZhenYi
395fa1b498 refactor(agent): update AI chat execution, streaming and ReAct logic 2026-05-12 13:04:40 +08:00
ZhenYi
6921220cc2 refactor(migrate): consolidate all individual migrations into single init.sql 2026-05-12 13:04:28 +08:00
ZhenYi
1daab11ba4 feat(scripts): add deployment and build utility scripts
Replace old scripting approach with new build, deploy, push,
and uninstall utilities.
2026-05-11 17:08:29 +08:00
ZhenYi
ac9ffb2a7a feat(frontend): update UI components, skill pages, and hooks
Refactor ChatMessageList, ChannelSidebar, and skill detail/pages.
Add CreateSkillDialog and DeleteSkillDialog. Update MarkdownRenderer,
use-mobile hook, and useSkillsQuery.
2026-05-11 17:06:13 +08:00
ZhenYi
de85417053 refactor(service): update service layer, TOTP, and AI streaming
Refine room AI streaming logic, update TOTP auth error handling,
and adjust user 2FA migration order. Remove unused service exports.
2026-05-11 17:05:59 +08:00
ZhenYi
0f800da74d chore(api): update Cargo dependency and build script 2026-05-11 17:05:50 +08:00
ZhenYi
ec29673d14 chore: update dependencies and project config
Update Cargo dependencies and bump gingress actix-web version.
Adjust package.json scripts.
2026-05-11 17:05:39 +08:00
ZhenYi
3b17a0493f refactor(git/ssh): extract helper functions into dedicated modules
Move RefUpdate, GitService, branch_protection check, and forward
function from handle.rs into separate modules.
2026-05-11 17:05:30 +08:00
ZhenYi
deb25614ba refactor(transport): split handler inbound and types into sub-modules
Extract MessageHandler methods into inbound/{message,room,reaction,misc,msg}
and type definitions into types/{in_message,out_event}.
2026-05-11 17:05:17 +08:00
ZhenYi
d45e9e28f4 refactor(agent): split monolithic service files into specialized modules
Extract agent, compact, embed, task, and modes modules from single
service.rs files into focused sub-modules. Add orao module for
O1-like reasoning loop. Move RigAgentService to rig_tool.rs.
2026-05-11 17:04:57 +08:00
ZhenYi
129aa3dce7 fix(gingress): use ENTRYPOINT instead of CMD so Kubernetes args are passed correctly
Without ENTRYPOINT, Kubernetes replaces CMD with args, causing the container
to try executing "--ingress-class=gingress" as a binary directly.
2026-05-11 01:34:17 +08:00
ZhenYi
b1ef024724 fix(migrate): reorder create_project before add_workspace_id_to_project and remove foreign keys
- Move create_project migration before add_workspace_id_to_project so the
  project table exists when workspace_id column is added
- Remove all FOREIGN KEY constraints from migration SQL files
2026-05-11 01:31:33 +08:00
ZhenYi
4d5caffe0b fix(deploy): disable readOnlyRootFilesystem to prevent temp file write errors
Email worker and other pods fail with "Read-only file system" when
readOnlyRootFilesystem is true, since they need to write temp files.
Also adds debug print statements for database connection lifecycle.
2026-05-11 01:14:06 +08:00
ZhenYi
bf7e6cf0a0 fix(app): initialize tracing immediately and fix replica timeout units
- Change init_tracing_subscriber defer=false so logs appear before DB
  connection fails (was deferred when OTEL enabled, producing no output)
- Fix replica connection pool timeouts from from_millis to from_secs
  (write connection was fixed earlier but replica was missed)
2026-05-11 00:43:36 +08:00
ZhenYi
fc013b174f fix(ops): preserve resources on deploy failure and protect ConfigMap/PVC from deletion
- deploy.sh: keep failed release for debugging instead of auto-uninstall,
  add helm.sh/resource-policy=keep annotation on ConfigMap and PVC
- uninstall.sh: interactive confirmation with protected resource list,
  post-uninstall verification that namespace/ConfigMap/PVC still exist
2026-05-11 00:17:43 +08:00
ZhenYi
b560d9ea0f fix(db): use seconds for connection pool timeouts instead of milliseconds
ConfigMap values are in seconds (e.g. connection_timeout=30 means 30s),
but Duration::from_millis() interpreted them as ms (30ms), causing pool
timeout on startup. Changed to from_secs(). Also removed Namespace from
Helm chart to prevent cascade deletion of PVC/ConfigMap on uninstall.
2026-05-10 23:58:16 +08:00
ZhenYi
065c9e6aa5 fix(deploy): replace underscores with hyphens in container names and fix namespace Helm ownership
Kubernetes container names must follow RFC 1123 (no underscores).
Also update deploy.sh to label/annotate namespace with Helm ownership metadata.
2026-05-10 23:23:45 +08:00
ZhenYi
f082429a58 feat(core): initialize project with access control and AI integration 2026-05-10 22:52:16 +08:00
ZhenYi
1c81036938 feat(ops): add deploy.sh for Helm-based deployment
Automates namespace creation, prerequisite checks, chart lint,
helm upgrade --install with wait, and post-deploy verification.
2026-05-10 22:50:40 +08:00
ZhenYi
1f025ee957 fix(deploy): unify gingress namespace to app 2026-05-10 22:49:03 +08:00
ZhenYi
7148c8fd39 feat(gingress): add Git UA routing and convert gingress to Helm templates
- Route requests with git/JGit User-Agent directly to gitserver backend
- Parse gingress.io/git-backend annotation (format: namespace/name:port)
- Convert static gingress YAML to Helm templates under deploy/templates/gingress/
- Add gingress config block to values.yaml (namespace, replicas, ports, resources)
2026-05-10 22:47:18 +08:00
ZhenYi
670bcc8c06 feat(deploy): configure ingress with gingress, cert-manager TLS, and SSH LB
- Set primary domain gitdata.ai and static.gitdata.ai with cert-manager TLS
- Add LoadBalancer service for gitserver SSH (port 2222)
- Exclude .server.yaml from Helm packaging
2026-05-10 22:29:32 +08:00
ZhenYi
003f0477f4 feat(core): initialize project with access control and AI integration 2026-05-10 22:15:52 +08:00
ZhenYi
b8c1dc5958 feat(core): initialize project with access control and AI integration 2026-05-10 22:02:38 +08:00
ZhenYi
4e2a39a5c0 fix(workspace): resolve all cargo check warnings across workspace
Remove unused imports and add #[allow(dead_code)] annotations to
intentionally retained fields/methods. Also add deploy/.server.yaml
to .gitignore to prevent accidental credential exposure.
2026-05-10 21:56:08 +08:00
ZhenYi
2a7c8f0ff2 feat(core): initialize project with access control and AI integration 2026-05-10 21:06:56 +08:00
ZhenYi
ba2490dab4 feat(core): initialize project with access control and AI integration 2026-05-10 21:01:21 +08:00
ZhenYi
14f6e1e500 feat(core): initialize project with access control and AI integration
- Add gitignore and prettier configuration files for project scaffolding
- Implement room access control service with project member verification
- Create user access key management with CRUD operations and activity logging
- Add accordion UI component for frontend expandable sections
- Implement room AI configuration with list, upsert, and delete operations
- Add AI event types for agent join/leave/status change tracking
- Create streaming AI processing services for mode and react patterns
- Build room AI service with model detection and idempotency handling
- Integrate chat service orchestration for AI message processing
- Add typing indicators and stream cancellation for AI interactions
- Implement mention parsing and context extraction for AI agents
2026-05-03 06:04:31 +08:00
2546 changed files with 110705 additions and 162508 deletions

View File

@ -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/

View File

@ -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)

View File

@ -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

View File

@ -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 -->

View File

@ -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
View File

@ -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
View File

@ -0,0 +1,11 @@
{
"mcpServers": {
"shadcn": {
"command": "npx",
"args": [
"shadcn@latest",
"mcp"
]
}
}
}

7
.prettierignore Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

View File

@ -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
View File

@ -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
View File

@ -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

View File

@ -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 -->

View File

@ -1 +0,0 @@
@AGENTS.md

View File

@ -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"]

View File

@ -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 CRUDUser/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 管理RedisTTL 可配置)
- [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 / SAMLRust 主应用 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 弹窗 UINext.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 前端全部完成。

View File

@ -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.

View File

@ -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` — 充值 APIINSERT 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 / SAMLRust 后端)
**现状**: 平台用户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` cookieAdmin 无法直接调用。
解决方案:
1. **共享 Session 存储**Admin 验证后注入 fake session 到 Redis主应用可读取
2. **Admin 专用 API**Rust 新增 `/api/admin/*` 路由,验证 `admin_session` cookie
3. **API Token**Rust API 支持 Bearer TokenAdmin 用服务账号 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
```

File diff suppressed because it is too large Load Diff

View File

@ -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

View File

@ -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 -}}

View File

@ -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 }}

View File

@ -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 }}

View File

@ -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 }}

View File

@ -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

View File

@ -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: { }

View File

@ -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;

View File

@ -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 导入 GrafanaDashboard → 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
...
```

View File

@ -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

File diff suppressed because it is too large Load Diff

View File

@ -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"
}
}

View File

@ -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",
},
});

View File

@ -1,7 +0,0 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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('');

View File

@ -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');

View File

@ -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('');

View File

@ -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}`);
});
});

View File

@ -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>
);
}

View File

@ -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 &lt;your-token&gt;</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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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} &lt;{c.authorEmail}&gt;</td>
<td style={{ fontSize: "12px" }}>{format(parseISO(c.createdAt), "yyyy-MM-dd HH:mm")}</td>
</tr>
))}
</tbody>
</table>
</div>
)
)}
</div>
</div>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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 });
}
}

View File

@ -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 });
}
}

View File

@ -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 });
}
}

View File

@ -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 });
}
}

View File

@ -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 });
}
}

View File

@ -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 });
}
}

View File

@ -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 });
}
}

View File

@ -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 });
}
}

View File

@ -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 });
}
}

View File

@ -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 });
}
}

View File

@ -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 });
}
}

View File

@ -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 });
}
}

View File

@ -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 });
}
}

View File

@ -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;
}
}

View File

@ -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 });
}
}

View File

@ -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);
}

View File

@ -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;
}

View File

@ -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 });
}
}

View File

@ -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 });
}
}

View File

@ -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 },
});
}

View File

@ -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 });
}
}

View File

@ -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 });
}
}

View File

@ -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 });
}
}

View File

@ -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 });
}
}

View File

@ -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`);
}

View File

@ -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 });
}
}

View File

@ -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 });
}
}

View File

@ -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 });
}
}

View File

@ -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 });
}
}

View File

@ -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 });
}
}

View File

@ -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 });
}
}

View File

@ -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 });
}
}

View File

@ -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 });
}
}

View File

@ -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 });
}
}

View File

@ -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 });
}
}

View File

@ -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 });
}
}

View File

@ -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 });
}
}

View File

@ -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 });
}
}

View File

@ -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