chore: ARTEX 初始化
ARTEX — AI 自主渗透测试系统(Go 后端 + Next.js 前端,单二进制内嵌)。 Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
commit
0ef8561411
287 files changed
+70178
No files matched your search
@@ -0,0 +1,37 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
.yarn/install-state.gz
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
*.sh
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
@@ -0,0 +1,3 @@
|
||||
npm run generate:presets
|
||||
git add src/lib/preferences/theme.ts
|
||||
npm exec -- lint-staged
|
||||
@@ -0,0 +1,96 @@
|
||||
# AGENTS.md
|
||||
|
||||
## Project overview
|
||||
|
||||
Studio Admin is a responsive admin dashboard built with Next.js 16, React 19, TypeScript, Tailwind CSS v4, and shadcn/ui.
|
||||
|
||||
This repository uses the shadcn `radix-nova` style. The shadcn CLI reports `base: "radix"`, which refers to Radix UI. Always inspect the local components in `src/components/ui/` because individual wrappers may use different primitives.
|
||||
|
||||
<!-- BEGIN:nextjs-agent-rules -->
|
||||
|
||||
# Next.js: ALWAYS read docs before coding
|
||||
|
||||
Before any Next.js work, find and read the relevant doc in `node_modules/next/dist/docs/`. Your training data is outdated — the docs are the source of truth.
|
||||
|
||||
<!-- END:nextjs-agent-rules -->
|
||||
|
||||
## shadcn skill
|
||||
|
||||
Use the shadcn skill for all work involving shadcn/ui components, styling, composition, registries, presets, or `components.json`.
|
||||
|
||||
If the skill is not available, install it with:
|
||||
|
||||
```bash
|
||||
npx skills add shadcn/ui
|
||||
```
|
||||
|
||||
The skill contains the component, styling, composition, accessibility, and CLI rules. Do not duplicate those rules here. Always inspect the local component source before using it.
|
||||
|
||||
Do not modify files inside `src/components/ui/` or `src/components/calendar/`. Keep these components intact and apply styling or customization where they are used.
|
||||
|
||||
## Setup
|
||||
|
||||
This project uses npm.
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Available commands:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
npm run lint
|
||||
npm run format
|
||||
npm run check
|
||||
npm run check:fix
|
||||
npm run generate:presets
|
||||
```
|
||||
|
||||
There is currently no automated test command. Run build, lint, check, or other validation commands only when the user explicitly requests that validation.
|
||||
|
||||
## Co-location-based structure
|
||||
|
||||
Keep feature code close to the route that owns it.
|
||||
|
||||
- Dashboard routes: `src/app/(main)/dashboard/<screen>/page.tsx`
|
||||
- Screen-specific components, data, and schemas: `src/app/(main)/dashboard/<screen>/_components/`
|
||||
- Shared dashboard components: `src/app/(main)/dashboard/_components/`
|
||||
- Shared application components: `src/components/`
|
||||
- Local shadcn components: `src/components/ui/`
|
||||
- Shared hooks and utilities: `src/hooks/` and `src/lib/`
|
||||
- Theme presets: `src/styles/presets/`
|
||||
|
||||
Keep a component inside its route until it is reused by another feature. Do not move screen-specific code into a shared directory preemptively.
|
||||
|
||||
## Creating or extending a screen
|
||||
|
||||
1. Inspect the closest current screen before writing code. Finance, Infrastructure, CRM, and Analytics are useful references. Do not use routes under `(legacy)` as references for new screens unless maintaining a legacy route.
|
||||
2. When reproducing a UI from a screenshot or image, follow its visual direction closely, including layout, hierarchy, spacing, component structure, and important details. Implement it with the project's existing components and semantic theme tokens rather than copying raw color values. If the design needs a color that is not available through the existing theme tokens, or the user explicitly requests a non-theme color, use a named color from Tailwind's default palette. Do not use arbitrary hex, RGB, HSL, or OKLCH values.
|
||||
3. Reuse the existing dashboard shell, local components, layout controls, and theme tokens.
|
||||
4. Break each new page into focused components inside the route's `_components/` directory. Keep `page.tsx` small and focused on composing those pieces.
|
||||
5. Keep `page.tsx` as a Server Component by default. Move interactive or browser-dependent code into a dedicated Client Component.
|
||||
6. Add the screen to `src/navigation/sidebar/sidebar-items.ts` when it should appear in the dashboard navigation.
|
||||
7. Decide the information hierarchy before choosing widgets. Let the content determine the page structure.
|
||||
8. Keep the established visual rhythm where it fits: compact spacing, clear typography hierarchy, responsive action rows, and grids that collapse cleanly on smaller screens.
|
||||
9. Widget selection is not a fixed formula. Try different arrangements of cards, resource rows, meters, charts, tabs, empty states, and actions, then keep the version that communicates the content clearly and feels consistent with the project.
|
||||
10. Match nearby screens in card density, borders, radius, spacing, content width, and responsive behavior.
|
||||
11. Use semantic theme tokens so new screens work with light mode, dark mode, and the existing theme presets.
|
||||
12. Handle relevant loading, empty, error, disabled, and overflow states.
|
||||
13. Keep screens accessible with semantic HTML, keyboard support, visible focus states, labels, and appropriate ARIA attributes.
|
||||
|
||||
## Code conventions
|
||||
|
||||
- TypeScript strict mode is enabled. Use precise types and avoid `any`.
|
||||
- Use the existing `@/` import aliases.
|
||||
- Follow the Biome configuration: double quotes, semicolons, two-space indentation, sorted imports, and a 120-character line width.
|
||||
- Avoid unnecessary dependencies.
|
||||
- Keep changes focused and do not refactor unrelated files.
|
||||
|
||||
## Contributions
|
||||
|
||||
- Use conventional commit prefixes such as `feat:`, `fix:`, `refactor:`, `docs:`, and `chore:`.
|
||||
- Include screenshots for new screens and material visual changes. Include mobile and dark-theme states when relevant.
|
||||
- Explain new reusable patterns or dependencies in the pull request.
|
||||
- Follow `CONTRIBUTING.md` for the contribution workflow.
|
||||
@@ -0,0 +1,127 @@
|
||||
# Contributing to Studio Admin
|
||||
|
||||
Thanks for showing interest in improving **Studio Admin** (repo: `next-shadcn-admin-dashboard`).
|
||||
This guide will help you set up your environment and understand how to contribute.
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
This project is built with **Next.js 16**, **TypeScript**, **Tailwind CSS v4**, and **Shadcn UI**.
|
||||
The goal is to keep the codebase modular, scalable, and easy to extend.
|
||||
|
||||
---
|
||||
|
||||
## Project Layout
|
||||
|
||||
We use a **colocation-based file system**. Each feature keeps its own pages, components, and logic.
|
||||
|
||||
```
|
||||
src
|
||||
├── app # Next.js routes (App Router)
|
||||
│ ├── (auth) # Auth layouts & screens
|
||||
│ ├── (main) # Main dashboard routes
|
||||
│ │ └── (dashboard)
|
||||
│ │ ├── crm
|
||||
│ │ ├── finance
|
||||
│ │ ├── default
|
||||
│ │ └── ...
|
||||
│ └── layout.tsx
|
||||
├── components # Shared UI components
|
||||
├── hooks # Reusable hooks
|
||||
├── lib # Config & utilities
|
||||
├── styles # Tailwind / theme setup
|
||||
└── types # TypeScript definitions
|
||||
```
|
||||
|
||||
If you’d like a more detailed example of this setup, check out the [Next Colocation Template](https://github.com/arhamkhnz/next-colocation-template), where the full structure is explained with examples.
|
||||
|
||||
---
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Fork and Clone the Repository
|
||||
|
||||
1. Fork the Repository
|
||||
|
||||
Click [here](https://github.com/arhamkhnz/next-shadcn-admin-dashboard/fork) to fork the repository.
|
||||
|
||||
2. Clone the Repository
|
||||
```bash
|
||||
git clone https://github.com/YOUR_USERNAME/next-shadcn-admin-dashboard.git
|
||||
```
|
||||
|
||||
3. Navigate into the Project
|
||||
```bash
|
||||
cd next-shadcn-admin-dashboard
|
||||
```
|
||||
|
||||
4. **Install dependencies**
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
5. **Run the dev server**
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
App will be available at [http://localhost:3000](http://localhost:3000).
|
||||
|
||||
---
|
||||
|
||||
## Contribution Flow
|
||||
|
||||
- Always create a new branch before working on changes:
|
||||
```bash
|
||||
git checkout -b feature/my-update
|
||||
```
|
||||
|
||||
- Use clear commit messages:
|
||||
```bash
|
||||
git commit -m "feat: add finance dashboard screen"
|
||||
```
|
||||
|
||||
- Open a Pull Request once ready.
|
||||
- If your change adds a new UI screen or component, include a screenshot in your PR description.
|
||||
|
||||
---
|
||||
|
||||
## Where to Contribute
|
||||
|
||||
- **External Pages**: Landing pages or other non-dashboard routes → `src/app/(external)/`
|
||||
- **Auth Screens**: Login, register, and authentication layouts → `src/app/(main)/auth/`
|
||||
- **Dashboard Screens**: Feature dashboards like CRM, Finance, Analytics → `src/app/(main)/dashboard/`
|
||||
- **Components**: Reusable UI goes in `src/components/`
|
||||
- **Hooks**: Custom logic goes in `src/hooks/`
|
||||
- **Themes**: New presets under `src/styles/presets/`
|
||||
|
||||
---
|
||||
|
||||
## Guidelines
|
||||
|
||||
- Prefer **TypeScript types** over `any`
|
||||
- Husky pre-commit hooks are enabled - linting and formatting run automatically when you commit, and if there are errors the commit will be blocked until they are fixed.
|
||||
- Follow **Shadcn UI** style & Tailwind v4 conventions
|
||||
- Keep accessibility in mind (ARIA, keyboard nav)
|
||||
- Use clear commit messages with conventional prefixes (`feat:`, `fix:`, `chore:`, etc.)
|
||||
- Avoid unnecessary dependencies — prefer existing utilities where possible
|
||||
|
||||
---
|
||||
|
||||
## Submitting PRs
|
||||
|
||||
- Open a Pull Request once your changes are ready.
|
||||
- Ensure your branch is up to date with `main` before submitting.
|
||||
- Reference any related issue in your PR for context.
|
||||
|
||||
---
|
||||
|
||||
## Questions & Support
|
||||
|
||||
- Report bugs, suggestions, or issues via [GitHub Issues](https://github.com/arhamkhnz/next-shadcn-admin-dashboard/issues)
|
||||
|
||||
---
|
||||
|
||||
Your contributions keep this project growing. 🚀
|
||||
|
||||
**Happy Vibe Coding!**
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024 Mohammed Arham Khan
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
# Next.js Admin Template with TypeScript & Shadcn UI
|
||||
|
||||
**Studio Admin** - Includes multiple dashboards, authentication layouts, customizable theme presets, and more.
|
||||
|
||||
<img src="https://github.com/arhamkhnz/next-shadcn-admin-dashboard/blob/main/media/dashboard.png?version=5" alt="Dashboard Screenshot">
|
||||
|
||||
Most admin templates I found, free or paid, felt cluttered, outdated, or too rigid. I built this as a cleaner alternative with features often missing in others, such as theme toggling and layout controls, while keeping the design modern, minimal, and flexible.
|
||||
|
||||
> **View demo:** [studio admin](https://next-shadcn-admin-dashboard.vercel.app)
|
||||
|
||||
> [!NOTE]
|
||||
> Looking for the Base UI version? Check out [next-shadcn-admin-dashboard-baseui](https://github.com/arhamkhnz/next-shadcn-admin-dashboard-baseui).
|
||||
|
||||
> [!TIP]
|
||||
> I’m also working on Nuxt.js, Svelte, and React (Vite + TanStack Router) versions of this dashboard. They’ll be live soon.
|
||||
|
||||
## Features
|
||||
|
||||
- Built with Next.js 16, TypeScript, Tailwind CSS v4, and Shadcn UI
|
||||
- Responsive and mobile-friendly
|
||||
- Customizable theme presets (light/dark modes with color schemes like Tangerine, Brutalist, and more)
|
||||
- Flexible layouts (collapsible sidebar, variable content widths)
|
||||
- Authentication flows and screens
|
||||
- Prebuilt dashboards (Default, CRM, Finance, Analytics, Productivity) plus legacy variants
|
||||
- Role-Based Access Control (RBAC) with config-driven UI and multi-tenant support *(planned)*
|
||||
|
||||
> [!NOTE]
|
||||
> The default dashboard uses the **shadcn neutral** theme.
|
||||
> It also includes additional color presets inspired by [Tweakcn](https://tweakcn.com):
|
||||
>
|
||||
> - Tangerine
|
||||
> - Neo Brutalism
|
||||
> - Soft Pop
|
||||
>
|
||||
> You can create more presets by following the same structure as the existing ones.
|
||||
|
||||
> Looking for the **Next.js 15** version?
|
||||
> Check out the [`archive/next15`](https://github.com/arhamkhnz/next-shadcn-admin-dashboard/tree/archive/next15) branch.
|
||||
> This branch contains the setup prior to upgrading to Next 16 and the React Compiler.
|
||||
|
||||
> Looking for the **Next.js 14 + Tailwind CSS v3** version?
|
||||
> Check out the [`archive/next14-tailwindv3`](https://github.com/arhamkhnz/next-shadcn-admin-dashboard/tree/archive/next14-tailwindv3) branch.
|
||||
> It has a different color theme and is not actively maintained, but I try to keep it updated with major changes.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Framework**: Next.js 16 (App Router), TypeScript, Tailwind CSS v4
|
||||
- **UI Components**: Shadcn UI
|
||||
- **Validation**: Zod
|
||||
- **Forms & State Management**: React Hook Form, Zustand
|
||||
- **Tables & Data Handling**: TanStack Table
|
||||
- **Tooling & DX**: Biome, Husky
|
||||
|
||||
## Screens
|
||||
|
||||
### Available
|
||||
- Default Dashboard
|
||||
- CRM Dashboard
|
||||
- Finance Dashboard
|
||||
- Analytics Dashboard
|
||||
- Productivity Dashboard
|
||||
- E-commerce Dashboard
|
||||
- Academy Dashboard
|
||||
- Logistics Dashboard
|
||||
- Infrastructure Dashboard
|
||||
- Chat Page
|
||||
- Email Page
|
||||
- Users Management
|
||||
- Roles Management
|
||||
- Kanban Board
|
||||
- Tasks Page
|
||||
- Invoice Page
|
||||
- Calendar Page
|
||||
- Authentication (4 screens)
|
||||
- Legacy: Default v1, CRM v1, Finance v1, Analytics v1
|
||||
|
||||
### Planned
|
||||
I’ve added all the planned screens. Feel free to open an issue for requesting something specific.
|
||||
|
||||
## Colocation File System Architecture
|
||||
|
||||
This project follows a **colocation-based architecture** each feature keeps its own pages, components, and logic inside its route folder.
|
||||
Shared UI, hooks, and configuration live at the top level, making the codebase modular, scalable, and easier to maintain as the app grows.
|
||||
|
||||
For a full breakdown of the structure with examples, see the [Next Colocation Template](https://github.com/arhamkhnz/next-colocation-template).
|
||||
|
||||
## Getting Started
|
||||
|
||||
You can run this project locally, or deploy it instantly with Vercel.
|
||||
|
||||
### Deploy with Vercel
|
||||
|
||||
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Farhamkhnz%2Fnext-shadcn-admin-dashboard)
|
||||
|
||||
_Deploy your own copy with one click._
|
||||
|
||||
### Run locally
|
||||
|
||||
1. **Clone the repository**
|
||||
```bash
|
||||
git clone https://github.com/arhamkhnz/next-shadcn-admin-dashboard.git
|
||||
```
|
||||
|
||||
2. **Navigate into the project**
|
||||
```bash
|
||||
cd next-shadcn-admin-dashboard
|
||||
```
|
||||
|
||||
3. **Install dependencies**
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
4. **Start the development server**
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Your app will be running at [http://localhost:3000](http://localhost:3000)
|
||||
|
||||
### Formatting and Linting
|
||||
|
||||
Format, lint, and organize imports
|
||||
```bash
|
||||
npx @biomejs/biome check --write
|
||||
```
|
||||
> For more information on available rules, fixes, and CLI options, refer to the [Biome documentation](https://biomejs.dev/).
|
||||
|
||||
---
|
||||
|
||||
> [!IMPORTANT]
|
||||
> This project is updated frequently. If you’re working from a fork or an older clone, pull the latest changes before syncing. Some updates may include breaking changes.
|
||||
|
||||
---
|
||||
|
||||
Contributions are welcome. Feel free to open issues, feature requests, or start a discussion.
|
||||
|
||||
|
||||
**Happy Vibe Coding!**
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.5.1/schema.json",
|
||||
"vcs": {
|
||||
"enabled": true,
|
||||
"clientKind": "git",
|
||||
"useIgnoreFile": true
|
||||
},
|
||||
"assist": {
|
||||
"enabled": true,
|
||||
"actions": {
|
||||
"source": {
|
||||
"organizeImports": {
|
||||
"level": "on",
|
||||
"options": {
|
||||
"groups": [
|
||||
"react",
|
||||
"react/**",
|
||||
":BLANK_LINE:",
|
||||
"next/**",
|
||||
":BLANK_LINE:",
|
||||
":PACKAGE:",
|
||||
":BLANK_LINE:",
|
||||
":ALIAS:",
|
||||
":BLANK_LINE:",
|
||||
":PATH:"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"files": {
|
||||
"ignoreUnknown": true,
|
||||
"includes": [
|
||||
"**",
|
||||
"!.github",
|
||||
"!.husky",
|
||||
"!node_modules",
|
||||
"!.next",
|
||||
"!dist",
|
||||
"!out",
|
||||
"!src/components/ui",
|
||||
"!src/components/calendar"
|
||||
]
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"formatWithErrors": false,
|
||||
"indentStyle": "space",
|
||||
"indentWidth": 2,
|
||||
"lineEnding": "lf",
|
||||
"lineWidth": 120,
|
||||
"attributePosition": "auto",
|
||||
"bracketSameLine": false,
|
||||
"bracketSpacing": true,
|
||||
"expand": "auto",
|
||||
"useEditorconfig": true
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"preset": "recommended",
|
||||
"correctness": {
|
||||
"noUndeclaredVariables": "error",
|
||||
"noNestedComponentDefinitions": "error",
|
||||
"noUndeclaredDependencies": "error"
|
||||
},
|
||||
"nursery": {
|
||||
"useSortedClasses": "on",
|
||||
"useNullishCoalescing": "error",
|
||||
"noFloatingPromises": "error",
|
||||
"noMisusedPromises": "error"
|
||||
},
|
||||
"performance": {
|
||||
"noImgElement": "warn"
|
||||
},
|
||||
"style": {
|
||||
"noCommonJs": "error",
|
||||
"noHeadElement": "warn",
|
||||
"noNamespace": "error",
|
||||
"useAsConstAssertion": "error",
|
||||
"useSelfClosingElements": "error",
|
||||
"noNestedTernary": "warn",
|
||||
"noParameterAssign": "error",
|
||||
"noInferrableTypes": "error",
|
||||
"noUselessElse": "error",
|
||||
"useFilenamingConvention": "error"
|
||||
},
|
||||
"suspicious": {
|
||||
"noArrayIndexKey": "warn",
|
||||
"noConstantBinaryExpressions": "error",
|
||||
"noEmptyBlockStatements": "error",
|
||||
"noUnknownAtRules": "off",
|
||||
"noImportCycles": "error",
|
||||
"noUnnecessaryConditions": "warn"
|
||||
},
|
||||
"complexity": {
|
||||
"noImportantStyles": "off"
|
||||
}
|
||||
},
|
||||
"domains": {
|
||||
"next": "recommended",
|
||||
"react": "recommended"
|
||||
}
|
||||
},
|
||||
"css": {
|
||||
"parser": {
|
||||
"tailwindDirectives": true
|
||||
}
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"jsxQuoteStyle": "double",
|
||||
"quoteProperties": "asNeeded",
|
||||
"trailingCommas": "all",
|
||||
"semicolons": "always",
|
||||
"arrowParentheses": "always",
|
||||
"bracketSameLine": false,
|
||||
"quoteStyle": "double",
|
||||
"attributePosition": "auto",
|
||||
"bracketSpacing": true
|
||||
}
|
||||
},
|
||||
"html": {
|
||||
"formatter": {
|
||||
"indentScriptAndStyle": false,
|
||||
"selfCloseVoidElements": "always"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "radix-nova",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/app/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"rtl": false,
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"menuColor": "default",
|
||||
"menuAccent": "subtle",
|
||||
"registries": {}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
@@ -0,0 +1,39 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
|
||||
// 静态导出:`NEXT_EXPORT=1 next build` 产出纯静态目录到 web/out,可直接丢进
|
||||
// nginx web 根目录运行。开发(next dev)不设该变量,保留 /api 反代与热更新。
|
||||
const isExport = process.env.NEXT_EXPORT === "1";
|
||||
// Vercel demo:整站走 mock,无后端,无需 /api 反代。
|
||||
const isMock = process.env.NEXT_PUBLIC_MOCK === "1";
|
||||
|
||||
const nextConfig = {
|
||||
reactCompiler: true,
|
||||
// 允许从局域网 IP 访问 dev 资源(HMR),按需增删。
|
||||
// dev 阶段放开任意 IPv4 来源访问 /_next/* 与 HMR(局域网 IP 变动也不受影响)。
|
||||
// 注意:Next 出于安全禁止裸 "*",需用分段通配;"*.*.*.*" 匹配任意 IPv4。
|
||||
allowedDevOrigins: ["*.*.*.*"],
|
||||
compiler: {
|
||||
removeConsole: process.env.NODE_ENV === "production",
|
||||
},
|
||||
...(isExport
|
||||
? {
|
||||
// 纯静态导出:无 Node 运行时;图片不经优化;每个路由产出 <route>/index.html。
|
||||
output: "export",
|
||||
images: { unoptimized: true },
|
||||
trailingSlash: true,
|
||||
}
|
||||
: isMock
|
||||
? {
|
||||
// Vercel mock demo:无后端,不需要 /api 反代。
|
||||
images: { unoptimized: true },
|
||||
}
|
||||
: {
|
||||
// 开发:把 /api/* 反代到 Go 后端(默认 :8787,可用 AUTOPENTEST_API 覆盖)。
|
||||
async rewrites() {
|
||||
const backend = process.env.AUTOPENTEST_API ?? "http://localhost:8787";
|
||||
return [{ source: "/api/:path*", destination: `${backend}/api/:path*` }];
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
Generated
+11120
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,81 @@
|
||||
{
|
||||
"name": "artex-web",
|
||||
"version": "2.2.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"build:static": "NEXT_EXPORT=1 next build",
|
||||
"start": "next start",
|
||||
"lint": "biome lint",
|
||||
"format": "biome format --write",
|
||||
"check": "biome check",
|
||||
"check:fix": "biome check --write",
|
||||
"prepare": "husky",
|
||||
"generate:presets": "ts-node -P tsconfig.scripts.json src/scripts/generate-theme-presets.ts"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{js,ts,jsx,tsx}": [
|
||||
"biome check --write --no-errors-on-unmatched"
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"@antv/graphin": "^3.0.5",
|
||||
"@base-ui/react": "^1.6.0",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/modifiers": "^9.0.0",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@fullcalendar/react": "^7.0.0",
|
||||
"@hookform/resolvers": "^5.4.0",
|
||||
"@shadcn/react": "^0.1.0",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@xyflow/react": "^12.3.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"d3-geo": "^3.1.1",
|
||||
"date-fns": "^4.4.0",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"geist": "^1.7.2",
|
||||
"input-otp": "^1.4.2",
|
||||
"lucide-react": "^1.22.0",
|
||||
"next": "^16.2.9",
|
||||
"next-themes": "^0.4.6",
|
||||
"radix-ui": "^1.6.0",
|
||||
"react": "^19.2.7",
|
||||
"react-day-picker": "^10.0.1",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-hook-form": "^7.80.0",
|
||||
"react-is": "^19.2.7",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-resizable-panels": "^4.12.0",
|
||||
"recharts": "^3.8.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"shadcn": "^4.12.0",
|
||||
"simple-icons": "^16.24.1",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"temporal-polyfill": "^1.0.1",
|
||||
"topojson-client": "^3.1.0",
|
||||
"vaul": "^1.1.2",
|
||||
"zod": "^4.4.3",
|
||||
"zustand": "^5.0.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.5.1",
|
||||
"@tailwindcss/postcss": "^4.3.2",
|
||||
"@types/d3-geo": "^3.1.0",
|
||||
"@types/node": "^22.20.0",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/topojson-client": "^3.1.5",
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^16.4.0",
|
||||
"postcss": "^8.5.16",
|
||||
"tailwindcss": "^4.1.5",
|
||||
"ts-node": "^10.9.2",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/** @type {import('postcss-load-config').Config} */
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 481 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1080" height="1080"><path fill="#fdfdfd" d="M0 0h1080v1080H0z"/><path fill="#6b34db" d="m391.25 285.75 2.104-.003c18.669.018 36.669 2.134 54.646 7.253l3.348.828c34.524 8.884 66.852 29.782 94.168 52.152a317 317 0 0 0 5.843 4.633c6.278 4.866 12.356 9.814 18.203 15.199 2.638 2.368 5.364 4.592 8.126 6.813 3.736 3.023 7.202 6.129 10.53 9.592 1.776 1.777 3.616 3.418 5.52 5.056 4.01 3.485 7.9 7.08 11.762 10.727l4.23 3.984 2.154 2.03q3.011 2.823 6.042 5.627c8.396 7.788 16.568 15.766 24.605 23.926 4.005 4.051 8.036 7.856 12.469 11.433a281 281 0 0 1 5 5l2.312 2.188C664 454 664 454 664 456l1.789.795c2.327 1.268 3.763 2.566 5.581 4.483l1.96 2.048 2.119 2.245 4.637 4.853 2.48 2.603c5.269 5.511 10.598 10.964 15.915 16.429 4.662 4.798 9.301 9.615 13.899 14.474a973 973 0 0 0 13.514 13.973 325 325 0 0 1 5.087 5.342c14.47 15.52 14.47 15.52 25.885 16.011 8.761-.193 17.186-1.402 25.134-5.256l2.703-1.25c22.559-11.344 37.824-31.091 45.97-54.7 3.597-11.1 4.508-21.338 4.515-32.925l.037-3.395c.036-20.761-7.343-39.562-20.225-55.73l-2.02-2.559c-10.263-12.205-27.064-24.128-43.285-25.777-30.302-.331-55.423 17.552-76.746 37.598-2.213 2.079-4.48 4.084-6.773 6.074-4.324 3.765-8.508 7.665-12.676 11.602l-2.145 2.013c-4.875 4.595-9.643 9.287-14.355 14.049-4.951-4.147-9.593-8.465-14.125-13.062l-1.801-1.815a2112 2112 0 0 1-3.332-3.365c-2.24-2.261-4.491-4.509-6.742-6.758l-4.148-4.148-2.365-2.365L592 365l2.892-3.381c.537-.628 1.073-1.256 1.627-1.902 4.96-5.748 10.57-10.529 16.481-15.276a292 292 0 0 0 7.544-6.384C648.12 314.169 681.294 296.655 717 289l3.277-.711c15.705-3.109 31.8-2.501 47.723-2.289l2.293.022c28.037.433 59.44 12.169 81.707 28.978l2.566 1.898c14.127 10.99 26.625 24.14 36.434 39.102l1.906 2.863c13.48 21.66 22.077 48.294 22.332 73.825l.03 2.46c.171 17.921.101 35.348-4.268 52.852l-.567 2.273c-6.162 23.584-18.357 45.395-34.183 63.789l-1.645 1.914c-2.985 3.398-6.021 6.244-9.605 9.024a637 637 0 0 0-5 5c-30.741 28.335-71.452 43.389-113 42-44.726-2.298-82.442-30.454-114.323-59.558a751 751 0 0 0-5.135-4.642c-11.057-10.022-21.535-20.641-32.073-31.201a6046 6046 0 0 0-8.401-8.397q-3.279-3.275-6.553-6.553a2260 2260 0 0 0-3.098-3.095c-8.977-8.961-8.977-8.961-12.879-13.516-4.738-5.522-9.949-10.593-15.097-15.729l-3.342-3.346q-3.486-3.488-6.977-6.972a6196 6196 0 0 1-8.879-8.882q-3.446-3.45-6.895-6.895-1.637-1.636-3.271-3.273C514.226 434.08 508.324 428.352 502 423q-1.678-1.459-3.352-2.922a527 527 0 0 0-22.7-18.564 998 998 0 0 1-6.409-5.034C461.23 389.934 452.893 383.734 444 378a881 881 0 0 1-3.469-2.352 373 373 0 0 0-26.292-16.354c-2.31-1.335-4.571-2.736-6.837-4.146-12.784-7.681-23.605-9.272-38.234-6.394-22.496 6.259-38.818 23.764-50.133 43.402C305.172 416.848 302.664 444.912 310 472c3.116 10.381 7.538 20.638 13 30l2 1 1.031-1.775a4360 4360 0 0 1 23.285-39.609 947 947 0 0 0 9.649-16.577c3.603-6.341 7.354-12.585 11.162-18.806a1832 1832 0 0 0 16.998-28.295c12.49-21.225 12.49-21.225 15.875-23.938 3.438 0 3.438 0 7 1 4.384 4.534 7.254 10.378 10.375 15.812l3.773 6.52 1.941 3.362c2.954 5.111 5.933 10.208 8.911 15.306l3.5 6 1.75 3 7 12 3.498 5.996q4.397 7.535 8.787 15.074a4694 4694 0 0 0 18.489 31.481c4.322 7.304 8.56 14.654 12.788 22.011 4.408 7.667 8.847 15.294 13.536 22.793 2.611 4.179 5.13 8.412 7.652 12.645 16.315-7.481 31.366-18.486 45.496-29.469l2.196-1.696a308 308 0 0 0 4.132-3.266c4.662-3.624 4.662-3.624 7.209-3.353 3.248 1.295 5.401 3.869 7.779 6.346l1.617 1.65a222 222 0 0 1 9.93 10.929c1.802 2.042 3.7 3.948 5.641 5.859 3.881 3.829 7.452 7.863 11 12q1.967 2.251 3.938 4.5L614 554c-11.23 16.515-27.545 29.058-44 40l-2.309 1.617C555.599 603.592 540.2 608.506 526 611l-2.824.551c-22.266 3.149-44.113-2.495-62.582-15.207-15.223-11.469-23.927-26.713-33.421-42.879a1072 1072 0 0 0-13.423-22.277l-1.589-2.565c-2.117-3.401-4.248-6.773-6.52-10.072C404 516 404 516 404 514h-2l-.664 2.461c-1.69 4.478-4.1 8.171-6.711 12.164q-1.65 2.566-3.293 5.137l-1.693 2.646c-2.51 3.969-4.923 7.994-7.327 12.03a3388 3388 0 0 1-15.91 26.366c-3.709 6.094-7.409 12.191-11.05 18.325l-1.666 2.803a1143 1143 0 0 0-3.02 5.117l-1.346 2.267-1.148 1.952c-1.251 1.848-2.449 3.309-4.172 4.732-4.7-.263-9.005-1.904-13.375-3.562l-2.097-.794c-7.777-3.021-15.178-6.71-22.528-10.644l-3.215-1.715C291.043 586.727 280.746 578.221 271 569l-2.488-2.328C256.722 555.303 248.1 542.123 240 528l-1.133-1.947C220.79 494.513 214.286 449.494 222 414l.489-2.359C227.531 387.559 237.036 366.538 252 347l2.168-2.891L256 342h2l.785-1.77c6.935-12.731 23.26-21.621 35.215-29.23l2.957-1.906c16.635-10.237 35.078-16.036 54.105-19.782l3.3-.651c12.283-2.317 24.404-2.898 36.888-2.911"/><path fill="#393bd1" d="m311 330 1 2 2.177-.476C329.387 328.7 349 327.855 363 335c2.168.336 2.168.336 4.188.375 5.721.468 10.944 2.617 15.812 5.625l1 3 5 1v1l-3.043.297c-22.61 2.393-40.542 11.337-55.543 28.586-17.45 22.532-27.681 50.862-24.109 79.566 2.553 16.784 8.126 32.865 16.695 47.551l2 1 1.031-1.775a4360 4360 0 0 1 23.285-39.609 947 947 0 0 0 9.649-16.577c3.603-6.341 7.354-12.585 11.162-18.806a18Line truncated
|
||||
|
After Width: | Height: | Size: 62 KiB |
@@ -0,0 +1,5 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export default function AuthLayout({ children }: { readonly children: ReactNode }) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { api } from "@/lib/api";
|
||||
import { auth } from "@/lib/auth";
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [checking, setChecking] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
// 已登录直接进主界面(静态导出下无 middleware 代劳这层跳转)。
|
||||
if (auth.getToken()) {
|
||||
router.replace("/function/tasks");
|
||||
return;
|
||||
}
|
||||
api.authStatus()
|
||||
.then(({ initialized }) => {
|
||||
if (!initialized) router.replace("/setup");
|
||||
})
|
||||
.catch(() => setError("无法连接到后端服务"))
|
||||
.finally(() => setChecking(false));
|
||||
}, [router]);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const { token } = await api.login("ARTEX", password);
|
||||
auth.setToken(token);
|
||||
router.replace("/function/tasks");
|
||||
} catch {
|
||||
setError("用户名或密码错误");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (checking) return null;
|
||||
|
||||
return (
|
||||
<div className="flex h-dvh">
|
||||
{/* Left panel */}
|
||||
<div className="hidden flex-col items-center justify-center bg-primary p-12 text-center lg:flex lg:w-1/3">
|
||||
<div className="relative flex items-center justify-center">
|
||||
<div className="absolute size-80 rounded-full border border-primary-foreground/10" />
|
||||
<div className="absolute size-60 rounded-full border border-primary-foreground/15" />
|
||||
<div className="absolute size-40 rounded-full border border-primary-foreground/20" />
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src="/logo.png"
|
||||
alt="ARTEX"
|
||||
width={160}
|
||||
height={160}
|
||||
className="relative brightness-0 invert"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right panel */}
|
||||
<div className="flex w-full items-center justify-center bg-background p-8 lg:w-2/3">
|
||||
<div className="w-full max-w-md space-y-10 py-24 lg:py-32">
|
||||
<div className="space-y-4 text-center">
|
||||
<h2 className="text-2xl font-medium tracking-tight">登录</h2>
|
||||
<p className="mx-auto max-w-xl text-muted-foreground">欢迎回来,请输入密码以继续使用 ARTEX</p>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="username">用户名</Label>
|
||||
<Input id="username" value="ARTEX" readOnly className="bg-muted text-muted-foreground" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="password">密码</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="请输入密码"
|
||||
autoFocus
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
<Button type="submit" className="w-full" disabled={loading || !password}>
|
||||
{loading ? "登录中..." : "登录"}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { api } from "@/lib/api";
|
||||
import { auth } from "@/lib/auth";
|
||||
|
||||
export default function SetupPage() {
|
||||
const router = useRouter();
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirm, setConfirm] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [checking, setChecking] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
api.authStatus()
|
||||
.then(({ initialized }) => {
|
||||
if (initialized) router.replace("/login");
|
||||
})
|
||||
.catch(() => setError("无法连接到后端服务"))
|
||||
.finally(() => setChecking(false));
|
||||
}, [router]);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (password !== confirm) {
|
||||
setError("两次输入的密码不一致");
|
||||
return;
|
||||
}
|
||||
if (password.length < 8) {
|
||||
setError("密码长度至少 8 位");
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const { token } = await api.initPassword(password);
|
||||
auth.setToken(token);
|
||||
router.replace("/function/tasks");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "初始化失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (checking) return null;
|
||||
|
||||
return (
|
||||
<div className="flex h-dvh">
|
||||
{/* Left panel */}
|
||||
<div className="hidden flex-col items-center justify-center bg-primary p-12 text-center lg:flex lg:w-1/3">
|
||||
<div className="relative flex items-center justify-center">
|
||||
<div className="absolute size-80 rounded-full border border-primary-foreground/10" />
|
||||
<div className="absolute size-60 rounded-full border border-primary-foreground/15" />
|
||||
<div className="absolute size-40 rounded-full border border-primary-foreground/20" />
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src="/logo.png"
|
||||
alt="ARTEX"
|
||||
width={160}
|
||||
height={160}
|
||||
className="relative brightness-0 invert"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right panel */}
|
||||
<div className="flex w-full items-center justify-center bg-background p-8 lg:w-2/3">
|
||||
<div className="w-full max-w-md space-y-10 py-24 lg:py-32">
|
||||
<div className="space-y-4 text-center">
|
||||
<h2 className="text-2xl font-medium tracking-tight">初始化密码</h2>
|
||||
<p className="mx-auto max-w-xl text-muted-foreground">首次使用 ARTEX,请为账户设置一个登录密码(至少 8 位)</p>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="password">新密码</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="至少 8 位"
|
||||
autoFocus
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="confirm">确认密码</Label>
|
||||
<Input
|
||||
id="confirm"
|
||||
type="password"
|
||||
value={confirm}
|
||||
onChange={(e) => setConfirm(e.target.value)}
|
||||
placeholder="再次输入密码"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
<Button type="submit" className="w-full" disabled={loading || !password || !confirm}>
|
||||
{loading ? "保存中..." : "设置密码并登录"}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function Home() {
|
||||
redirect("/function/tasks");
|
||||
return <>Coming Soon</>;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { SidebarTrigger } from "@/components/ui/sidebar";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useCurrentUser } from "@/hooks/use-current-user";
|
||||
|
||||
import { AccountSwitcher } from "./sidebar/account-switcher";
|
||||
import { LayoutControls } from "./sidebar/layout-controls";
|
||||
import { SearchDialog } from "./sidebar/search-dialog";
|
||||
import { ThemeSwitcher } from "./sidebar/theme-switcher";
|
||||
|
||||
// 任务详情页保持原样:它自带头部/Tabs 与内边距,这里不再叠加全局头部和 padding。
|
||||
function isFullBleed(pathname: string) {
|
||||
const p = (() => {
|
||||
try {
|
||||
return decodeURIComponent(pathname);
|
||||
} catch {
|
||||
return pathname;
|
||||
}
|
||||
})();
|
||||
return p.startsWith("/function/tasks/");
|
||||
}
|
||||
|
||||
export function MainContent({ children }: { children: ReactNode }) {
|
||||
const currentUser = useCurrentUser();
|
||||
const pathname = usePathname();
|
||||
|
||||
if (isFullBleed(pathname)) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<header
|
||||
className={cn(
|
||||
"flex h-12 shrink-0 items-center gap-2 border-b transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12",
|
||||
"[html[data-navbar-style=sticky]_&]:sticky [html[data-navbar-style=sticky]_&]:top-0 [html[data-navbar-style=sticky]_&]:z-50 [html[data-navbar-style=sticky]_&]:overflow-hidden [html[data-navbar-style=sticky]_&]:rounded-t-[inherit] [html[data-navbar-style=sticky]_&]:bg-background/50 [html[data-navbar-style=sticky]_&]:backdrop-blur-md",
|
||||
)}
|
||||
>
|
||||
<div className="flex w-full items-center justify-between px-4 lg:px-6">
|
||||
<div className="flex items-center gap-1 lg:gap-2">
|
||||
<SidebarTrigger className="-ml-1" />
|
||||
<Separator
|
||||
orientation="vertical"
|
||||
className="mx-2 data-[orientation=vertical]:h-4 data-[orientation=vertical]:self-center"
|
||||
/>
|
||||
<SearchDialog />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<LayoutControls />
|
||||
<ThemeSwitcher />
|
||||
<AccountSwitcher users={[currentUser]} />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div className="min-h-0 min-w-0 flex-1 overflow-x-hidden p-4 has-data-[content-padding=false]:p-0 md:p-6 md:has-data-[content-padding=false]:p-0">
|
||||
{children}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { Check, KeyRound, LogOut } from "lucide-react";
|
||||
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { cn, getInitials } from "@/lib/utils";
|
||||
|
||||
import { ChangePasswordDialog } from "./change-password-dialog";
|
||||
|
||||
export function AccountSwitcher({
|
||||
users,
|
||||
}: {
|
||||
readonly users: ReadonlyArray<{
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly email: string;
|
||||
readonly avatar: string;
|
||||
readonly role: string;
|
||||
}>;
|
||||
}) {
|
||||
const [activeUser, setActiveUser] = useState(users[0]);
|
||||
const [pwOpen, setPwOpen] = useState(false);
|
||||
|
||||
function handleLogout() {
|
||||
auth.clearToken();
|
||||
window.location.href = "/login";
|
||||
}
|
||||
|
||||
if (!activeUser) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Avatar className="size-8 rounded-lg">
|
||||
<AvatarImage src={activeUser.avatar || undefined} alt={activeUser.name} />
|
||||
<AvatarFallback>{getInitials(activeUser.name)}</AvatarFallback>
|
||||
</Avatar>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="min-w-56 space-y-1 rounded-lg" side="bottom" align="end" sideOffset={4}>
|
||||
{users.map((user) => (
|
||||
<DropdownMenuItem
|
||||
key={user.email}
|
||||
className={cn("p-0", user.id === activeUser.id && "bg-accent/50")}
|
||||
aria-current={user.id === activeUser.id ? "true" : undefined}
|
||||
onClick={() => setActiveUser(user)}
|
||||
>
|
||||
<div className="flex w-full items-center gap-2 px-1 py-1.5">
|
||||
<Avatar className="size-9 rounded-lg">
|
||||
<AvatarImage src={user.avatar || undefined} alt={user.name} />
|
||||
<AvatarFallback>{getInitials(user.name)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="grid min-w-0 flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-semibold">{user.name}</span>
|
||||
<span className="truncate text-xs capitalize">{user.role}</span>
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"mr-1 flex size-5 items-center justify-center rounded-full text-primary opacity-0",
|
||||
user.id === activeUser.id && "opacity-100",
|
||||
)}
|
||||
>
|
||||
<Check aria-hidden="true" />
|
||||
</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => setPwOpen(true)}>
|
||||
<KeyRound />
|
||||
修改密码
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleLogout} className="text-destructive focus:text-destructive">
|
||||
<LogOut />
|
||||
退出登录
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<ChangePasswordDialog open={pwOpen} onOpenChange={setPwOpen} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { APP_CONFIG } from "@/config/app-config";
|
||||
import { useCurrentUser } from "@/hooks/use-current-user";
|
||||
import { sidebarItems } from "@/navigation/sidebar/sidebar-items";
|
||||
import { usePreferencesStore } from "@/stores/preferences/preferences-provider";
|
||||
|
||||
import { NavMain } from "./nav-main";
|
||||
import { NavUser } from "./nav-user";
|
||||
import { SidebarSupportCard } from "./sidebar-support-card";
|
||||
|
||||
|
||||
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
const currentUser = useCurrentUser();
|
||||
const { sidebarVariant, sidebarCollapsible, isSynced } = usePreferencesStore(
|
||||
useShallow((s) => ({
|
||||
sidebarVariant: s.sidebarVariant,
|
||||
sidebarCollapsible: s.sidebarCollapsible,
|
||||
isSynced: s.isSynced,
|
||||
})),
|
||||
);
|
||||
|
||||
const variant = isSynced ? sidebarVariant : props.variant;
|
||||
const collapsible = isSynced ? sidebarCollapsible : props.collapsible;
|
||||
|
||||
return (
|
||||
<Sidebar {...props} variant={variant} collapsible={collapsible}>
|
||||
<SidebarHeader>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton asChild>
|
||||
<Link prefetch={false} href="/function/tasks">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src="/logo.png" alt="ARTEX" width={40} height={40} className="shrink-0" />
|
||||
<span className="font-semibold text-base">{APP_CONFIG.name}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
<NavMain items={sidebarItems} />
|
||||
{/* <NavDocuments items={data.documents} /> */}
|
||||
{/* <NavSecondary items={data.navSecondary} className="mt-auto" /> */}
|
||||
</SidebarContent>
|
||||
<SidebarFooter>
|
||||
<NavUser user={currentUser} />
|
||||
</SidebarFooter>
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { api } from "@/lib/api";
|
||||
|
||||
export function ChangePasswordDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
readonly open: boolean;
|
||||
readonly onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const [oldPassword, setOldPassword] = React.useState("");
|
||||
const [newPassword, setNewPassword] = React.useState("");
|
||||
const [confirmPassword, setConfirmPassword] = React.useState("");
|
||||
const [saving, setSaving] = React.useState(false);
|
||||
|
||||
// reset fields whenever the dialog closes
|
||||
React.useEffect(() => {
|
||||
if (!open) {
|
||||
setOldPassword("");
|
||||
setNewPassword("");
|
||||
setConfirmPassword("");
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!oldPassword || !newPassword) {
|
||||
toast.error("请填写当前密码和新密码");
|
||||
return;
|
||||
}
|
||||
if (newPassword !== confirmPassword) {
|
||||
toast.error("两次输入的新密码不一致");
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
api
|
||||
.changePassword(oldPassword, newPassword)
|
||||
.then(() => {
|
||||
toast.success("密码已修改");
|
||||
onOpenChange(false);
|
||||
})
|
||||
.catch((err) => toast.error(`修改失败:${(err as Error).message}`))
|
||||
.finally(() => setSaving(false));
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<form onSubmit={submit}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>修改密码</DialogTitle>
|
||||
<DialogDescription>
|
||||
用户名固定为 <b>ARTEX</b>。需先输入当前密码验证;修改后已签发的登录 token 仍有效直至过期。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-3 py-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="cp-old">当前密码</Label>
|
||||
<Input
|
||||
id="cp-old"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={oldPassword}
|
||||
disabled={saving}
|
||||
onChange={(e) => setOldPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="cp-new">新密码</Label>
|
||||
<Input
|
||||
id="cp-new"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={newPassword}
|
||||
disabled={saving}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="cp-confirm">确认新密码</Label>
|
||||
<Input
|
||||
id="cp-confirm"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={confirmPassword}
|
||||
disabled={saving}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="submit" disabled={saving}>
|
||||
{saving ? "修改中…" : "确认修改"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
"use client";
|
||||
|
||||
import { Settings } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||
import { type FontKey, fontOptions } from "@/lib/fonts/registry";
|
||||
import type { ContentLayout, NavbarStyle, SidebarCollapsible, SidebarVariant } from "@/lib/preferences/layout";
|
||||
import {
|
||||
applyContentLayout,
|
||||
applyFont,
|
||||
applyNavbarStyle,
|
||||
applySidebarCollapsible,
|
||||
applySidebarVariant,
|
||||
} from "@/lib/preferences/layout-utils";
|
||||
import { PREFERENCE_DEFAULTS } from "@/lib/preferences/preferences-config";
|
||||
import { persistPreference } from "@/lib/preferences/preferences-storage";
|
||||
import { THEME_PRESET_OPTIONS, type ThemeMode, type ThemePreset } from "@/lib/preferences/theme";
|
||||
import { applyThemePreset } from "@/lib/preferences/theme-utils";
|
||||
import { usePreferencesStore } from "@/stores/preferences/preferences-provider";
|
||||
|
||||
export function LayoutControls() {
|
||||
const themeMode = usePreferencesStore((s) => s.themeMode);
|
||||
const resolvedThemeMode = usePreferencesStore((s) => s.resolvedThemeMode);
|
||||
const setThemeMode = usePreferencesStore((s) => s.setThemeMode);
|
||||
const themePreset = usePreferencesStore((s) => s.themePreset);
|
||||
const setThemePreset = usePreferencesStore((s) => s.setThemePreset);
|
||||
const contentLayout = usePreferencesStore((s) => s.contentLayout);
|
||||
const setContentLayout = usePreferencesStore((s) => s.setContentLayout);
|
||||
const navbarStyle = usePreferencesStore((s) => s.navbarStyle);
|
||||
const setNavbarStyle = usePreferencesStore((s) => s.setNavbarStyle);
|
||||
const variant = usePreferencesStore((s) => s.sidebarVariant);
|
||||
const setSidebarVariant = usePreferencesStore((s) => s.setSidebarVariant);
|
||||
const collapsible = usePreferencesStore((s) => s.sidebarCollapsible);
|
||||
const setSidebarCollapsible = usePreferencesStore((s) => s.setSidebarCollapsible);
|
||||
const font = usePreferencesStore((s) => s.font);
|
||||
const setFont = usePreferencesStore((s) => s.setFont);
|
||||
|
||||
const onThemePresetChange = (preset: ThemePreset) => {
|
||||
applyThemePreset(preset);
|
||||
setThemePreset(preset);
|
||||
void persistPreference("theme_preset", preset);
|
||||
};
|
||||
|
||||
const onThemeModeChange = (mode: ThemeMode | "") => {
|
||||
if (!mode) return;
|
||||
setThemeMode(mode);
|
||||
void persistPreference("theme_mode", mode);
|
||||
};
|
||||
|
||||
const onContentLayoutChange = (layout: ContentLayout | "") => {
|
||||
if (!layout) return;
|
||||
applyContentLayout(layout);
|
||||
setContentLayout(layout);
|
||||
void persistPreference("content_layout", layout);
|
||||
};
|
||||
|
||||
const onNavbarStyleChange = (style: NavbarStyle | "") => {
|
||||
if (!style) return;
|
||||
applyNavbarStyle(style);
|
||||
setNavbarStyle(style);
|
||||
void persistPreference("navbar_style", style);
|
||||
};
|
||||
|
||||
const onSidebarStyleChange = (value: SidebarVariant | "") => {
|
||||
if (!value) return;
|
||||
setSidebarVariant(value);
|
||||
applySidebarVariant(value);
|
||||
void persistPreference("sidebar_variant", value);
|
||||
};
|
||||
|
||||
const onSidebarCollapseModeChange = (value: SidebarCollapsible | "") => {
|
||||
if (!value) return;
|
||||
setSidebarCollapsible(value);
|
||||
applySidebarCollapsible(value);
|
||||
void persistPreference("sidebar_collapsible", value);
|
||||
};
|
||||
|
||||
const onFontChange = (value: FontKey | "") => {
|
||||
if (!value) return;
|
||||
applyFont(value);
|
||||
setFont(value);
|
||||
void persistPreference("font", value);
|
||||
};
|
||||
|
||||
const handleRestore = () => {
|
||||
onThemePresetChange(PREFERENCE_DEFAULTS.theme_preset);
|
||||
onThemeModeChange(PREFERENCE_DEFAULTS.theme_mode);
|
||||
onContentLayoutChange(PREFERENCE_DEFAULTS.content_layout);
|
||||
onNavbarStyleChange(PREFERENCE_DEFAULTS.navbar_style);
|
||||
onSidebarStyleChange(PREFERENCE_DEFAULTS.sidebar_variant);
|
||||
onSidebarCollapseModeChange(PREFERENCE_DEFAULTS.sidebar_collapsible);
|
||||
onFontChange(PREFERENCE_DEFAULTS.font);
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button size="icon">
|
||||
<Settings />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end">
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="space-y-1.5">
|
||||
<h4 className="font-medium text-sm leading-none">Preferences</h4>
|
||||
<p className="text-muted-foreground text-xs">Customize your dashboard layout preferences.</p>
|
||||
</div>
|
||||
<div className="space-y-3 **:data-[slot=toggle-group]:w-full **:data-[slot=toggle-group-item]:flex-1 **:data-[slot=toggle-group-item]:text-xs">
|
||||
<div className="space-y-1">
|
||||
<Label className="font-medium text-xs">Theme Preset</Label>
|
||||
<Select value={themePreset} onValueChange={onThemePresetChange}>
|
||||
<SelectTrigger size="sm" className="w-full text-xs">
|
||||
<SelectValue placeholder="Preset" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{THEME_PRESET_OPTIONS.map((preset) => (
|
||||
<SelectItem key={preset.value} className="text-xs" value={preset.value}>
|
||||
<span
|
||||
className="size-2.5 rounded-full"
|
||||
style={{
|
||||
backgroundColor:
|
||||
(resolvedThemeMode ?? "light") === "dark" ? preset.primary.dark : preset.primary.light,
|
||||
}}
|
||||
/>
|
||||
{preset.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="font-medium text-xs">Fonts</Label>
|
||||
<Select value={font} onValueChange={onFontChange}>
|
||||
<SelectTrigger size="sm" className="w-full text-xs">
|
||||
<SelectValue placeholder="Select font" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{fontOptions.map((font) => (
|
||||
<SelectItem key={font.key} className="text-xs" value={font.key}>
|
||||
{font.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="font-medium text-xs">Theme Mode</Label>
|
||||
<ToggleGroup
|
||||
size="sm"
|
||||
spacing={0}
|
||||
variant="outline"
|
||||
type="single"
|
||||
value={themeMode}
|
||||
onValueChange={onThemeModeChange}
|
||||
>
|
||||
<ToggleGroupItem value="light" aria-label="Toggle light">
|
||||
Light
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="dark" aria-label="Toggle dark">
|
||||
Dark
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="system" aria-label="Toggle system">
|
||||
System
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="font-medium text-xs">Page Layout</Label>
|
||||
<ToggleGroup
|
||||
size="sm"
|
||||
spacing={0}
|
||||
variant="outline"
|
||||
type="single"
|
||||
value={contentLayout}
|
||||
onValueChange={onContentLayoutChange}
|
||||
>
|
||||
<ToggleGroupItem value="centered" aria-label="Toggle centered">
|
||||
Centered
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="full-width" aria-label="Toggle full-width">
|
||||
Full Width
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="font-medium text-xs">Navbar Behavior</Label>
|
||||
<ToggleGroup
|
||||
size="sm"
|
||||
spacing={0}
|
||||
variant="outline"
|
||||
type="single"
|
||||
value={navbarStyle}
|
||||
onValueChange={onNavbarStyleChange}
|
||||
>
|
||||
<ToggleGroupItem value="sticky" aria-label="Toggle sticky">
|
||||
Sticky
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="scroll" aria-label="Toggle scroll">
|
||||
Scroll
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="font-medium text-xs">Sidebar Style</Label>
|
||||
<ToggleGroup
|
||||
size="sm"
|
||||
spacing={0}
|
||||
variant="outline"
|
||||
type="single"
|
||||
value={variant}
|
||||
onValueChange={onSidebarStyleChange}
|
||||
>
|
||||
<ToggleGroupItem value="inset" aria-label="Toggle inset">
|
||||
Inset
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="sidebar" aria-label="Toggle sidebar">
|
||||
Sidebar
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="floating" aria-label="Toggle floating">
|
||||
Floating
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="font-medium text-xs">Sidebar Collapse Mode</Label>
|
||||
<ToggleGroup
|
||||
size="sm"
|
||||
spacing={0}
|
||||
variant="outline"
|
||||
type="single"
|
||||
value={collapsible}
|
||||
onValueChange={onSidebarCollapseModeChange}
|
||||
>
|
||||
<ToggleGroupItem value="icon" aria-label="Toggle icon">
|
||||
Icon
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="offcanvas" aria-label="Toggle offcanvas">
|
||||
OffCanvas
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
|
||||
<Button type="button" size="sm" variant="outline" className="w-full text-xs" onClick={handleRestore}>
|
||||
Restore Defaults
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
"use client";
|
||||
|
||||
import { Ellipsis, Folder, Forward, type LucideIcon, Trash2 } from "lucide-react";
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupLabel,
|
||||
SidebarMenu,
|
||||
SidebarMenuAction,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from "@/components/ui/sidebar";
|
||||
|
||||
export function NavDocuments({
|
||||
items,
|
||||
}: {
|
||||
readonly items: readonly {
|
||||
readonly name: string;
|
||||
readonly url: string;
|
||||
readonly icon: LucideIcon;
|
||||
}[];
|
||||
}) {
|
||||
const { isMobile } = useSidebar();
|
||||
|
||||
return (
|
||||
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
|
||||
<SidebarGroupLabel>Documents</SidebarGroupLabel>
|
||||
<SidebarMenu>
|
||||
{items.map((item) => (
|
||||
<SidebarMenuItem key={item.name}>
|
||||
<SidebarMenuButton asChild>
|
||||
<a href={item.url}>
|
||||
<item.icon />
|
||||
<span>{item.name}</span>
|
||||
</a>
|
||||
</SidebarMenuButton>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuAction showOnHover className="rounded-sm data-[state=open]:bg-accent">
|
||||
<Ellipsis />
|
||||
<span className="sr-only">More</span>
|
||||
</SidebarMenuAction>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-24 rounded-lg"
|
||||
side={isMobile ? "bottom" : "right"}
|
||||
align={isMobile ? "end" : "start"}
|
||||
>
|
||||
<DropdownMenuItem>
|
||||
<Folder />
|
||||
<span>Open</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<Forward />
|
||||
<span>Share</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem variant="destructive">
|
||||
<Trash2 />
|
||||
<span>Delete</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton className="text-sidebar-foreground/70">
|
||||
<Ellipsis className="text-sidebar-foreground/70" />
|
||||
<span>More</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
import { ChevronRight, MailIcon, PlusCircleIcon } from "lucide-react";
|
||||
import { api } from "@/lib/api";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarMenu,
|
||||
SidebarMenuBadge,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
useSidebar,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type {
|
||||
NavBadge,
|
||||
NavGroup,
|
||||
NavMainItem,
|
||||
NavMainLinkItem,
|
||||
NavMainParentItem,
|
||||
} from "@/navigation/sidebar/sidebar-items";
|
||||
|
||||
interface NavMainProps {
|
||||
readonly items: readonly NavGroup[];
|
||||
}
|
||||
interface NavItemProps {
|
||||
readonly item: NavMainItem;
|
||||
readonly isItemActive: (item: NavMainItem) => boolean;
|
||||
readonly isSubItemActive: (url: string) => boolean;
|
||||
readonly isSubmenuOpen: (item: NavMainParentItem) => boolean;
|
||||
}
|
||||
|
||||
interface NavLinkItemProps {
|
||||
readonly item: NavMainLinkItem;
|
||||
readonly isActive: boolean;
|
||||
readonly showIconFallback: boolean;
|
||||
}
|
||||
|
||||
interface NavLinkIconProps {
|
||||
readonly item: NavMainLinkItem;
|
||||
readonly showFallback: boolean;
|
||||
}
|
||||
|
||||
interface NavDropdownItemProps {
|
||||
readonly item: NavMainParentItem;
|
||||
readonly isActive: boolean;
|
||||
readonly isSubItemActive: (url: string) => boolean;
|
||||
}
|
||||
|
||||
interface NavCollapsibleItemProps {
|
||||
readonly item: NavMainParentItem;
|
||||
readonly isActive: boolean;
|
||||
readonly defaultOpen: boolean;
|
||||
readonly isSubItemActive: (url: string) => boolean;
|
||||
}
|
||||
|
||||
function CollapsedIconFallback({ title }: { title: string }) {
|
||||
return (
|
||||
<span className="flex size-4 shrink-0 items-center justify-center rounded-xs font-medium text-[10px] outline">
|
||||
{title.slice(0, 1)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function hasSubItems(item: NavMainItem): item is NavMainParentItem {
|
||||
return Boolean(item.subItems?.length);
|
||||
}
|
||||
|
||||
export function NavMain({ items }: NavMainProps) {
|
||||
const rawPath = usePathname();
|
||||
// 路由段含中文,pathname 可能是百分号编码,解码后再与导航 url 比较,保证高亮命中。
|
||||
const path = (() => {
|
||||
try {
|
||||
return decodeURIComponent(rawPath);
|
||||
} catch {
|
||||
return rawPath;
|
||||
}
|
||||
})();
|
||||
|
||||
const isItemActive = (item: NavMainItem) => {
|
||||
if (hasSubItems(item)) {
|
||||
return item.subItems.some((sub) => path.startsWith(sub.url));
|
||||
}
|
||||
|
||||
return path === item.url;
|
||||
};
|
||||
|
||||
const isSubItemActive = (url: string) => {
|
||||
return path === url;
|
||||
};
|
||||
|
||||
const isSubmenuOpen = (item: NavMainParentItem) => {
|
||||
return item.subItems.some((sub) => path.startsWith(sub.url));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{items.map((group) => (
|
||||
<SidebarGroup key={group.id}>
|
||||
{group.label && (
|
||||
<SidebarGroupLabel className="group-data-[collapsible=icon]:pointer-events-none">
|
||||
{group.label}
|
||||
</SidebarGroupLabel>
|
||||
)}
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{group.items.map((item) => (
|
||||
<NavItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
isItemActive={isItemActive}
|
||||
isSubItemActive={isSubItemActive}
|
||||
isSubmenuOpen={isSubmenuOpen}
|
||||
/>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function NavItem({ item, isItemActive, isSubItemActive, isSubmenuOpen }: NavItemProps) {
|
||||
const { state, isMobile } = useSidebar();
|
||||
const isCollapsedDesktop = state === "collapsed" && !isMobile;
|
||||
|
||||
if (!hasSubItems(item)) {
|
||||
return <NavLinkItem item={item} isActive={isItemActive(item)} showIconFallback={isCollapsedDesktop} />;
|
||||
}
|
||||
|
||||
if (isCollapsedDesktop) {
|
||||
return <NavDropdownItem item={item} isActive={isItemActive(item)} isSubItemActive={isSubItemActive} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<NavCollapsibleItem
|
||||
item={item}
|
||||
isActive={isItemActive(item)}
|
||||
defaultOpen={isSubmenuOpen(item)}
|
||||
isSubItemActive={isSubItemActive}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// InterceptPendingBadge polls /api/intercept/pending every 5 s and shows a red
|
||||
// count badge when there are unresolved approval requests.
|
||||
function InterceptPendingBadge() {
|
||||
const [count, setCount] = React.useState(0);
|
||||
React.useEffect(() => {
|
||||
let live = true;
|
||||
async function poll() {
|
||||
try {
|
||||
const list = await api.interceptPending();
|
||||
if (live) setCount(list.length);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
poll();
|
||||
const t = setInterval(poll, 5000);
|
||||
return () => { live = false; clearInterval(t); };
|
||||
}, []);
|
||||
if (count === 0) return null;
|
||||
return (
|
||||
<SidebarMenuBadge className="flex h-4.5 min-w-[18px] items-center justify-center rounded-full bg-amber-500 px-1 text-[10px] font-semibold leading-none text-white shadow-sm ring-1 ring-amber-400/40">
|
||||
{count > 99 ? "99+" : count}
|
||||
</SidebarMenuBadge>
|
||||
);
|
||||
}
|
||||
|
||||
function NavLinkItem({ item, isActive, showIconFallback }: NavLinkItemProps) {
|
||||
return (
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton asChild aria-disabled={item.disabled} tooltip={item.title} isActive={isActive}>
|
||||
<Link
|
||||
prefetch={false}
|
||||
href={item.url}
|
||||
target={item.newTab ? "_blank" : undefined}
|
||||
rel={item.newTab ? "noreferrer" : undefined}
|
||||
>
|
||||
<NavLinkIcon item={item} showFallback={showIconFallback} />
|
||||
<span>{item.title}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
{item.id === "approvals" ? <InterceptPendingBadge /> : <NavItemBadge badge={item.badge} />}
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
function NavLinkIcon({ item, showFallback }: NavLinkIconProps) {
|
||||
const Icon = item.icon;
|
||||
|
||||
if (Icon) {
|
||||
return <Icon />;
|
||||
}
|
||||
|
||||
if (showFallback) {
|
||||
return <CollapsedIconFallback title={item.title} />;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function NavDropdownItem({ item, isActive, isSubItemActive }: NavDropdownItemProps) {
|
||||
const Icon = item.icon;
|
||||
|
||||
return (
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuButton tooltip={item.title} isActive={isActive} disabled={item.disabled}>
|
||||
{Icon ? <Icon /> : <CollapsedIconFallback title={item.title} />}
|
||||
<span>{item.title}</span>
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent side="right" align="start" sideOffset={12} className="w-48">
|
||||
<DropdownMenuGroup>
|
||||
{item.subItems.map((subItem) => {
|
||||
const SubIcon = subItem.icon;
|
||||
|
||||
return (
|
||||
<DropdownMenuItem key={subItem.id} asChild disabled={subItem.disabled}>
|
||||
<Link
|
||||
prefetch={false}
|
||||
href={subItem.url}
|
||||
target={subItem.newTab ? "_blank" : undefined}
|
||||
rel={subItem.newTab ? "noreferrer" : undefined}
|
||||
aria-current={isSubItemActive(subItem.url) ? "page" : undefined}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
{SubIcon && <SubIcon />}
|
||||
<span>{subItem.title}</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
function NavCollapsibleItem({ item, isActive, defaultOpen, isSubItemActive }: NavCollapsibleItemProps) {
|
||||
const Icon = item.icon;
|
||||
|
||||
return (
|
||||
<Collapsible asChild defaultOpen={defaultOpen} className="group/collapsible">
|
||||
<SidebarMenuItem>
|
||||
<CollapsibleTrigger asChild>
|
||||
<SidebarMenuButton tooltip={item.title} isActive={isActive} disabled={item.disabled}>
|
||||
{Icon && <Icon />}
|
||||
<span>{item.title}</span>
|
||||
<ChevronRight className="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
|
||||
</SidebarMenuButton>
|
||||
</CollapsibleTrigger>
|
||||
<NavItemBadge badge={item.badge} />
|
||||
|
||||
<CollapsibleContent>
|
||||
<SidebarMenuSub>
|
||||
{item.subItems.map((subItem) => {
|
||||
const SubIcon = subItem.icon;
|
||||
|
||||
return (
|
||||
<SidebarMenuSubItem key={subItem.id}>
|
||||
<SidebarMenuSubButton
|
||||
asChild
|
||||
aria-disabled={subItem.disabled}
|
||||
isActive={isSubItemActive(subItem.url)}
|
||||
>
|
||||
<Link
|
||||
prefetch={false}
|
||||
href={subItem.url}
|
||||
target={subItem.newTab ? "_blank" : undefined}
|
||||
rel={subItem.newTab ? "noreferrer" : undefined}
|
||||
>
|
||||
{SubIcon && <SubIcon />}
|
||||
<span>{subItem.title}</span>
|
||||
</Link>
|
||||
</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
);
|
||||
})}
|
||||
</SidebarMenuSub>
|
||||
</CollapsibleContent>
|
||||
</SidebarMenuItem>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
function NavItemBadge({ badge }: { badge?: NavBadge }) {
|
||||
if (!badge) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarMenuBadge
|
||||
className={cn(
|
||||
"rounded-sm border capitalize",
|
||||
badge === "new" &&
|
||||
"border-green-600 text-green-600 peer-hover/menu-button:text-green-600 peer-data-active/menu-button:text-green-600",
|
||||
badge === "soon" && "border-muted-foreground text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{badge}
|
||||
</SidebarMenuBadge>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import type * as React from "react";
|
||||
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
} from "@/components/ui/sidebar";
|
||||
|
||||
export function NavSecondary({
|
||||
items,
|
||||
...props
|
||||
}: {
|
||||
items: {
|
||||
title: string;
|
||||
url: string;
|
||||
icon: LucideIcon;
|
||||
}[];
|
||||
} & React.ComponentPropsWithoutRef<typeof SidebarGroup>) {
|
||||
return (
|
||||
<SidebarGroup {...props}>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{items.map((item) => (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
<SidebarMenuButton asChild>
|
||||
<a href={item.url}>
|
||||
<item.icon />
|
||||
<span>{item.title}</span>
|
||||
</a>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { EllipsisVertical, KeyRound, LogOut } from "lucide-react";
|
||||
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { SidebarMenu, SidebarMenuButton, SidebarMenuItem, useSidebar } from "@/components/ui/sidebar";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { getInitials } from "@/lib/utils";
|
||||
|
||||
import { ChangePasswordDialog } from "./change-password-dialog";
|
||||
|
||||
export function NavUser({
|
||||
user,
|
||||
}: {
|
||||
readonly user: {
|
||||
readonly name: string;
|
||||
readonly email: string;
|
||||
readonly avatar: string;
|
||||
};
|
||||
}) {
|
||||
const { isMobile } = useSidebar();
|
||||
const [pwOpen, setPwOpen] = React.useState(false);
|
||||
|
||||
function handleLogout() {
|
||||
auth.clearToken();
|
||||
// 硬跳转:让浏览器用已清除的 cookie 发起全新请求,
|
||||
// middleware 才能正确读到空 token 并放行 /login
|
||||
window.location.href = "/login";
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuButton
|
||||
size="lg"
|
||||
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||
>
|
||||
<Avatar className="h-8 w-8 rounded-lg grayscale">
|
||||
<AvatarImage src={user.avatar || undefined} alt={user.name} />
|
||||
<AvatarFallback className="rounded-lg">{getInitials(user.name)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-medium">{user.name}</span>
|
||||
<span className="truncate text-muted-foreground text-xs">{user.email}</span>
|
||||
</div>
|
||||
<EllipsisVertical className="ml-auto size-4" />
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
|
||||
side={isMobile ? "bottom" : "right"}
|
||||
align="end"
|
||||
sideOffset={4}
|
||||
>
|
||||
<DropdownMenuLabel className="p-0 font-normal">
|
||||
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
|
||||
<Avatar className="h-8 w-8 rounded-lg">
|
||||
<AvatarImage src={user.avatar || undefined} alt={user.name} />
|
||||
<AvatarFallback className="rounded-lg">{getInitials(user.name)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-medium">{user.name}</span>
|
||||
<span className="truncate text-muted-foreground text-xs">{user.email}</span>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => setPwOpen(true)}>
|
||||
<KeyRound />
|
||||
修改密码
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={handleLogout} className="text-destructive focus:text-destructive">
|
||||
<LogOut />
|
||||
退出登录
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
<ChangePasswordDialog open={pwOpen} onOpenChange={setPwOpen} />
|
||||
</SidebarMenu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { Search } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
} from "@/components/ui/command";
|
||||
import type { NavMainItem } from "@/navigation/sidebar/sidebar-items";
|
||||
import { sidebarItems } from "@/navigation/sidebar/sidebar-items";
|
||||
|
||||
type SearchItem = {
|
||||
id: string;
|
||||
group: string;
|
||||
label: string;
|
||||
url: string;
|
||||
icon?: NavMainItem["icon"];
|
||||
disabled?: boolean;
|
||||
newTab?: boolean;
|
||||
};
|
||||
|
||||
const sidebarGroupLabels = new Set(sidebarItems.flatMap((group) => (group.label ? [group.label] : [])));
|
||||
|
||||
function getSubItemGroup(groupLabel: string | undefined, itemTitle: string) {
|
||||
return sidebarGroupLabels.has(itemTitle) ? (groupLabel ?? "Other") : itemTitle;
|
||||
}
|
||||
|
||||
const searchItems: SearchItem[] = sidebarItems.flatMap((group) =>
|
||||
group.items.flatMap((item) => {
|
||||
if (item.subItems) {
|
||||
return item.subItems.map((sub) => ({
|
||||
id: sub.id,
|
||||
group: getSubItemGroup(group.label, item.title),
|
||||
label: sub.title,
|
||||
url: sub.url,
|
||||
icon: item.icon,
|
||||
disabled: sub.disabled,
|
||||
newTab: sub.newTab,
|
||||
}));
|
||||
}
|
||||
return [
|
||||
{
|
||||
id: item.id,
|
||||
group: group.label ?? "Other",
|
||||
label: item.title,
|
||||
url: item.url,
|
||||
icon: item.icon,
|
||||
disabled: item.disabled,
|
||||
newTab: item.newTab,
|
||||
},
|
||||
];
|
||||
}),
|
||||
);
|
||||
|
||||
function getAvailableItems(items: SearchItem[]) {
|
||||
return items.filter((item) => !item.disabled && !item.url.includes("coming-soon"));
|
||||
}
|
||||
|
||||
const recommendations = getAvailableItems(searchItems);
|
||||
|
||||
function groupBy(items: SearchItem[]) {
|
||||
const groups = [...new Set(items.map((item) => item.group))];
|
||||
return groups.map((group) => ({
|
||||
group,
|
||||
items: items.filter((item) => item.group === group),
|
||||
}));
|
||||
}
|
||||
|
||||
export function SearchDialog() {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [query, setQuery] = React.useState("");
|
||||
const router = useRouter();
|
||||
|
||||
React.useEffect(() => {
|
||||
const down = (e: KeyboardEvent) => {
|
||||
if (e.key === "j" && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
setOpen((prev) => !prev);
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", down);
|
||||
return () => document.removeEventListener("keydown", down);
|
||||
}, []);
|
||||
|
||||
const handleOpenChange = (value: boolean) => {
|
||||
setOpen(value);
|
||||
if (!value) setQuery("");
|
||||
};
|
||||
|
||||
const handleSelect = (item: SearchItem) => {
|
||||
if (item.disabled) return;
|
||||
handleOpenChange(false);
|
||||
if (item.newTab) {
|
||||
window.open(item.url, "_blank", "noopener,noreferrer");
|
||||
} else {
|
||||
router.push(item.url);
|
||||
}
|
||||
};
|
||||
|
||||
const renderGroups = (items: SearchItem[]) =>
|
||||
groupBy(items).map(({ group, items: groupItems }, index) => (
|
||||
<React.Fragment key={group}>
|
||||
{index > 0 && <CommandSeparator />}
|
||||
<CommandGroup heading={group}>
|
||||
{groupItems.map((item) => (
|
||||
<CommandItem
|
||||
disabled={item.disabled}
|
||||
key={`${group}-${item.id}`}
|
||||
value={`${item.group} ${item.label}`}
|
||||
onSelect={() => handleSelect(item)}
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
{item.icon && <item.icon />}
|
||||
<span className="truncate">{item.label}</span>
|
||||
</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</React.Fragment>
|
||||
));
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
onClick={() => handleOpenChange(true)}
|
||||
variant="link"
|
||||
className="px-0! font-normal text-muted-foreground hover:no-underline"
|
||||
>
|
||||
<Search data-icon="inline-start" />
|
||||
Search
|
||||
<kbd className="inline-flex h-5 select-none items-center gap-1 rounded border bg-muted px-1.5 font-medium text-[10px]">
|
||||
<span className="text-xs">⌘</span>J
|
||||
</kbd>
|
||||
</Button>
|
||||
<CommandDialog open={open} onOpenChange={handleOpenChange}>
|
||||
<Command>
|
||||
<CommandInput placeholder="Search dashboards, users, and more…" value={query} onValueChange={setQuery} />
|
||||
<CommandList>
|
||||
<CommandEmpty>No results found.</CommandEmpty>
|
||||
{query ? renderGroups(searchItems) : renderGroups(recommendations)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</CommandDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import { siX } from "simple-icons";
|
||||
|
||||
import { SimpleIcon } from "@/components/simple-icon";
|
||||
import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
export function SidebarSupportCard() {
|
||||
return (
|
||||
<Card size="sm" className="overflow-hidden shadow-none group-data-[collapsible=icon]:hidden">
|
||||
<CardHeader className="min-w-0 px-4">
|
||||
<CardTitle className="truncate text-sm">Looking for something more?</CardTitle>
|
||||
<CardDescription className="line-clamp-2">
|
||||
Open an issue or do reach out to me on
|
||||
<Link
|
||||
href="https://x.com/arhamkhnz"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
aria-label="Reach out on X"
|
||||
className="inline-flex items-center text-foreground"
|
||||
>
|
||||
<SimpleIcon icon={siX} aria-hidden className="size-3 fill-current" />
|
||||
</Link>
|
||||
.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import { Monitor, Moon, Sun } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { persistPreference } from "@/lib/preferences/preferences-storage";
|
||||
import { usePreferencesStore } from "@/stores/preferences/preferences-provider";
|
||||
|
||||
const THEME_CYCLE = ["light", "dark", "system"] as const;
|
||||
|
||||
export function ThemeSwitcher() {
|
||||
const themeMode = usePreferencesStore((s) => s.themeMode);
|
||||
const setThemeMode = usePreferencesStore((s) => s.setThemeMode);
|
||||
|
||||
const cycleTheme = () => {
|
||||
const currentIndex = THEME_CYCLE.indexOf(themeMode);
|
||||
const nextTheme = THEME_CYCLE[(currentIndex + 1) % THEME_CYCLE.length];
|
||||
|
||||
setThemeMode(nextTheme);
|
||||
void persistPreference("theme_mode", nextTheme);
|
||||
};
|
||||
|
||||
return (
|
||||
<Button size="icon" onClick={cycleTheme} aria-label={`Current theme: ${themeMode}. Click to cycle themes`}>
|
||||
{/* SYSTEM */}
|
||||
<Monitor className="hidden [html[data-theme-mode=system]_&]:block" />
|
||||
|
||||
{/* DARK (resolved) */}
|
||||
<Sun className="hidden dark:block [html[data-theme-mode=system]_&]:hidden" />
|
||||
|
||||
{/* LIGHT (resolved) */}
|
||||
<Moon className="block dark:hidden [html[data-theme-mode=system]_&]:hidden" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,740 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Bot, ChevronDownIcon, PlusIcon, SendIcon, Square, Trash2Icon, ZapIcon } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Transcript } from "@/components/transcript";
|
||||
import { TodoPopover } from "@/components/todo-popover";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { api } from "@/lib/api";
|
||||
import type { Activity, Agent, Conversation, LLMProfile } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// fmtTokens renders a compact token count (1234 → 1.2k, 2_000_000 → 2M).
|
||||
function fmtTokens(n: number): string {
|
||||
if (n >= 1_000_000) return (n / 1_000_000).toFixed(n >= 10_000_000 ? 0 : 1) + "M";
|
||||
if (n >= 1000) return (n / 1000).toFixed(n >= 10000 ? 0 : 1) + "k";
|
||||
return String(n);
|
||||
}
|
||||
|
||||
// fmtDuration renders an elapsed milliseconds span compactly (90s → 1m30s).
|
||||
function fmtDuration(ms: number): string {
|
||||
const s = Math.floor(ms / 1000);
|
||||
if (s < 60) return `${s}s`;
|
||||
const m = Math.floor(s / 60);
|
||||
if (m < 60) return `${m}m${String(s % 60).padStart(2, "0")}s`;
|
||||
const h = Math.floor(m / 60);
|
||||
return `${h}h${String(m % 60).padStart(2, "0")}m`;
|
||||
}
|
||||
|
||||
// LiveBadge is the small pulsing "实时" chip reused from the task's main-agent
|
||||
// console — shown while a turn is streaming.
|
||||
function LiveBadge() {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 rounded bg-blue-500/15 px-1.5 py-0.5 text-[10px] font-medium text-blue-600 dark:text-blue-400">
|
||||
<span className="size-1 animate-pulse rounded-full bg-blue-500" />
|
||||
实时
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Composer is the shared bottom input (textarea grows to a cap, Enter sends,
|
||||
// Shift+Enter newlines) — the same affordance across DraftChat and ChatView.
|
||||
function Composer({
|
||||
value,
|
||||
onChange,
|
||||
onSend,
|
||||
disabled,
|
||||
placeholder,
|
||||
leftSlot,
|
||||
running,
|
||||
onStop,
|
||||
stopDisabled,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
onSend: () => void;
|
||||
disabled: boolean;
|
||||
placeholder: string;
|
||||
leftSlot?: React.ReactNode;
|
||||
running?: boolean;
|
||||
onStop?: () => void;
|
||||
stopDisabled?: boolean;
|
||||
}) {
|
||||
function onKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
onSend();
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div className="border-t p-3">
|
||||
<div className="flex items-end gap-2">
|
||||
{leftSlot}
|
||||
<Textarea
|
||||
className="max-h-40 min-h-10 flex-1 resize-none"
|
||||
rows={1}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
{running ? (
|
||||
// while a run is in flight the send button becomes a stop button —
|
||||
// aborts just this session (the trigger queue keeps going).
|
||||
<Button size="icon" variant="destructive" onClick={onStop} disabled={stopDisabled} title="停止本次运行">
|
||||
<Square className="size-3.5 fill-current" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="icon" onClick={onSend} disabled={disabled || !value.trim()}>
|
||||
<SendIcon className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// LLMProfileRow shows the active LLM config below the composer and lets the user
|
||||
// switch it via a Popover. `selected` is the profile id or null for default.
|
||||
function LLMProfileRow({
|
||||
profiles,
|
||||
selected,
|
||||
onChange,
|
||||
disabled,
|
||||
rightSlot,
|
||||
}: {
|
||||
profiles: LLMProfile[];
|
||||
selected: number | null;
|
||||
onChange: (id: number | null) => void;
|
||||
disabled?: boolean;
|
||||
rightSlot?: React.ReactNode;
|
||||
}) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const activeDefault = profiles.find((p) => p.is_default);
|
||||
const current = selected != null ? profiles.find((p) => Number(p.id) === selected) : null;
|
||||
const label = current ? current.name : `默认${activeDefault ? `(${activeDefault.name})` : ""}`;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 px-1 pb-1 pt-0.5">
|
||||
<ZapIcon className="text-muted-foreground/50 size-3 shrink-0" />
|
||||
<span className="text-muted-foreground/70 text-xs">{label}</span>
|
||||
<Popover open={open} onOpenChange={disabled ? undefined : setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
className="text-primary flex items-center gap-0.5 text-xs hover:underline disabled:pointer-events-none disabled:opacity-40"
|
||||
>
|
||||
更换
|
||||
<ChevronDownIcon className="size-3" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-64 p-1">
|
||||
<p className="text-muted-foreground px-2 py-1 text-[11px] font-medium">选择 LLM 配置</p>
|
||||
{/* default option */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { onChange(null); setOpen(false); }}
|
||||
className={cn(
|
||||
"flex w-full flex-col rounded px-2 py-1.5 text-left hover:bg-accent",
|
||||
selected == null && "bg-accent",
|
||||
)}
|
||||
>
|
||||
<span className="text-sm">默认{activeDefault ? `(${activeDefault.name})` : ""}</span>
|
||||
{activeDefault && (
|
||||
<span className="text-muted-foreground text-[11px]">{activeDefault.format} · {activeDefault.model}</span>
|
||||
)}
|
||||
</button>
|
||||
{profiles.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
onClick={() => { onChange(Number(p.id)); setOpen(false); }}
|
||||
className={cn(
|
||||
"flex w-full flex-col rounded px-2 py-1.5 text-left hover:bg-accent",
|
||||
selected === Number(p.id) && "bg-accent",
|
||||
)}
|
||||
>
|
||||
<span className="text-sm">{p.name}</span>
|
||||
<span className="text-muted-foreground text-[11px]">{p.format} · {p.model}</span>
|
||||
</button>
|
||||
))}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{rightSlot}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// DraftChat is the default right-pane view: a fresh chat (agent picker in the
|
||||
// header, centered empty state, composer) with NO conversation created yet. The
|
||||
// conversation is created lazily on the first send (ChatGPT-style), then the
|
||||
// parent switches to the real ChatView.
|
||||
function DraftChat({
|
||||
agents,
|
||||
profiles,
|
||||
onStarted,
|
||||
}: {
|
||||
agents: Agent[];
|
||||
profiles: LLMProfile[];
|
||||
onStarted: (c: Conversation) => void;
|
||||
}) {
|
||||
const [agentKey, setAgentKey] = React.useState("");
|
||||
const [llmProfileId, setLlmProfileId] = React.useState<number | null>(null);
|
||||
const [input, setInput] = React.useState("");
|
||||
const [sending, setSending] = React.useState(false);
|
||||
|
||||
// default the agent to Auto once agents load.
|
||||
React.useEffect(() => {
|
||||
if (!agentKey && agents.some((a) => a.key === "auto")) setAgentKey("auto");
|
||||
}, [agentKey, agents]);
|
||||
|
||||
const agent = agents.find((a) => a.key === agentKey);
|
||||
|
||||
async function send() {
|
||||
const msg = input.trim();
|
||||
if (!msg || !agentKey || sending) return;
|
||||
setSending(true);
|
||||
try {
|
||||
const c = await api.createConversation(agentKey, "", llmProfileId);
|
||||
await api.sendConversationMessage(c.id, msg);
|
||||
onStarted(c);
|
||||
} catch (e) {
|
||||
toast.error("发送失败:" + (e as Error).message);
|
||||
setSending(false);
|
||||
}
|
||||
}
|
||||
|
||||
const agentPicker = (
|
||||
<Select value={agentKey} onValueChange={setAgentKey}>
|
||||
<SelectTrigger size="sm" className="w-40 shrink-0">
|
||||
<SelectValue placeholder="选择 Agent…" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{agents.map((a) => (
|
||||
<SelectItem key={a.key} value={a.key}>
|
||||
<span className="flex items-center gap-2">
|
||||
<Bot className="size-3.5" />
|
||||
{a.name}
|
||||
{!a.builtin && (
|
||||
<Badge variant="outline" className="px-1 py-0 text-[9px]">自定义</Badge>
|
||||
)}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* empty / landing state fills the panel */}
|
||||
<div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-2 px-4 text-center">
|
||||
<div className="bg-primary/10 flex size-12 items-center justify-center rounded-full">
|
||||
<Bot className="text-primary size-6" />
|
||||
</div>
|
||||
<div className="text-sm font-medium">开始和「{agent?.name ?? "Agent"}」对话</div>
|
||||
{agent?.description && (
|
||||
<p className="text-muted-foreground max-w-md text-xs">{agent.description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Composer
|
||||
value={input}
|
||||
onChange={setInput}
|
||||
onSend={send}
|
||||
disabled={sending || !agentKey}
|
||||
placeholder="输入消息,Enter 发送,Shift+Enter 换行"
|
||||
leftSlot={agentPicker}
|
||||
/>
|
||||
<LLMProfileRow
|
||||
profiles={profiles}
|
||||
selected={llmProfileId}
|
||||
onChange={setLlmProfileId}
|
||||
disabled={sending}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ChatView is the right pane for one conversation — mirrors the task detail's
|
||||
// main-agent console: header with agent + live badge + token/duration meta, a
|
||||
// scroll-stick transcript (chat mode), and the composer.
|
||||
function ChatView({
|
||||
conv,
|
||||
agents,
|
||||
profiles,
|
||||
onTitleMaybeChanged,
|
||||
onConvUpdated,
|
||||
}: {
|
||||
conv: Conversation;
|
||||
agents: Agent[];
|
||||
profiles: LLMProfile[];
|
||||
onTitleMaybeChanged: () => void;
|
||||
onConvUpdated: () => void;
|
||||
}) {
|
||||
const [messages, setMessages] = React.useState<Activity[]>([]);
|
||||
const [running, setRunning] = React.useState(false);
|
||||
const [input, setInput] = React.useState("");
|
||||
const [sending, setSending] = React.useState(false);
|
||||
const [stopping, setStopping] = React.useState(false);
|
||||
const cursorRef = React.useRef(0);
|
||||
const agent = agents.find((a) => a.key === conv.agent_key);
|
||||
const currentProfileId = conv.llm_profile_id ?? null;
|
||||
|
||||
async function changeProfile(id: number | null) {
|
||||
try {
|
||||
await api.updateConversationProfile(conv.id, id);
|
||||
onConvUpdated();
|
||||
} catch (e) {
|
||||
toast.error("切换 LLM 失败:" + (e as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
// conversation-scoped detail fetcher for the reused Transcript renderer.
|
||||
const fetchDetail = React.useCallback(
|
||||
(seq: number) => api.conversationMsgDetail(conv.id, seq),
|
||||
[conv.id],
|
||||
);
|
||||
|
||||
// seq of the most-recent TodoWrite tool call (for the Todo popover); null if none.
|
||||
const latestTodoSeq = React.useMemo(() => {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const a = messages[i];
|
||||
if (a.kind === "tool_use" && a.tool === "TodoWrite") return a.seq;
|
||||
}
|
||||
return null;
|
||||
}, [messages]);
|
||||
|
||||
// reset + load whenever the selected conversation changes.
|
||||
React.useEffect(() => {
|
||||
cursorRef.current = 0;
|
||||
setMessages([]);
|
||||
setRunning(false);
|
||||
let live = true;
|
||||
api
|
||||
.conversationMessages(conv.id, 0)
|
||||
.then((r) => {
|
||||
if (!live) return;
|
||||
setMessages(r.items);
|
||||
cursorRef.current = r.cursor;
|
||||
setRunning(r.running);
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [conv.id]);
|
||||
|
||||
// poll while a turn is running: pull new steps after the cursor.
|
||||
React.useEffect(() => {
|
||||
if (!running) return;
|
||||
let live = true;
|
||||
const tick = async () => {
|
||||
try {
|
||||
const r = await api.conversationMessages(conv.id, cursorRef.current);
|
||||
if (!live) return;
|
||||
if (r.items.length) {
|
||||
setMessages((prev) => [...prev, ...r.items]);
|
||||
cursorRef.current = r.cursor;
|
||||
}
|
||||
setRunning(r.running);
|
||||
if (!r.running) onTitleMaybeChanged(); // first-turn auto-title landed
|
||||
} catch {
|
||||
/* transient — keep polling */
|
||||
}
|
||||
};
|
||||
const h = setInterval(tick, 1000);
|
||||
return () => {
|
||||
live = false;
|
||||
clearInterval(h);
|
||||
};
|
||||
}, [running, conv.id, onTitleMaybeChanged]);
|
||||
|
||||
// ---- transcript auto-scroll (open → bottom; stick to bottom unless scrolled up) ----
|
||||
const contentRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const atBottomRef = React.useRef(true);
|
||||
const viewport = React.useCallback(
|
||||
() =>
|
||||
(contentRef.current?.closest('[data-slot="scroll-area-viewport"]') as HTMLElement | null) ??
|
||||
null,
|
||||
[],
|
||||
);
|
||||
React.useEffect(() => {
|
||||
const vp = viewport();
|
||||
if (!vp) return;
|
||||
const onScroll = () => {
|
||||
atBottomRef.current = vp.scrollTop + vp.clientHeight >= vp.scrollHeight - 60;
|
||||
};
|
||||
vp.addEventListener("scroll", onScroll, { passive: true });
|
||||
return () => vp.removeEventListener("scroll", onScroll);
|
||||
}, [viewport]);
|
||||
// open/switch a conversation → jump to the latest (bottom)
|
||||
React.useLayoutEffect(() => {
|
||||
const vp = viewport();
|
||||
if (vp) {
|
||||
vp.scrollTop = vp.scrollHeight;
|
||||
atBottomRef.current = true;
|
||||
}
|
||||
}, [conv.id, viewport]);
|
||||
// new activity → stick to bottom only if the user is already pinned there
|
||||
React.useLayoutEffect(() => {
|
||||
if (!atBottomRef.current) return;
|
||||
const vp = viewport();
|
||||
if (vp) vp.scrollTop = vp.scrollHeight;
|
||||
}, [messages, running, viewport]);
|
||||
|
||||
// Per-conversation token total, live — same accounting as the main-agent
|
||||
// console: completed runs' `result` sum + the in-progress run's latest `usage`.
|
||||
const tokenTotal = React.useMemo(() => {
|
||||
let i = 0, o = 0, cr = 0;
|
||||
let li = 0, lo = 0, lcr = 0;
|
||||
let turns = 0; // agent 循环轮次 = 模型调用次数(每次一条 kind='usage')
|
||||
for (const a of messages) {
|
||||
if (a.kind === "result") {
|
||||
i += a.input_tokens ?? 0;
|
||||
o += a.output_tokens ?? 0;
|
||||
cr += a.cache_read_tokens ?? 0;
|
||||
li = lo = lcr = 0;
|
||||
} else if (a.kind === "usage") {
|
||||
turns += 1;
|
||||
li = a.input_tokens ?? 0;
|
||||
lo = a.output_tokens ?? 0;
|
||||
lcr = a.cache_read_tokens ?? 0;
|
||||
}
|
||||
}
|
||||
const I = i + li, O = o + lo, CR = cr + lcr;
|
||||
return { i: I, o: O, cr: CR, turns, any: I + O + CR > 0 };
|
||||
}, [messages]);
|
||||
|
||||
async function send() {
|
||||
const msg = input.trim();
|
||||
if (!msg || sending || running) return;
|
||||
setSending(true);
|
||||
setInput("");
|
||||
try {
|
||||
await api.sendConversationMessage(conv.id, msg);
|
||||
setRunning(true);
|
||||
// pull the just-persisted human turn immediately.
|
||||
const r = await api.conversationMessages(conv.id, cursorRef.current);
|
||||
setMessages((prev) => [...prev, ...r.items]);
|
||||
cursorRef.current = r.cursor;
|
||||
} catch (e) {
|
||||
toast.error("发送失败:" + (e as Error).message);
|
||||
setInput(msg); // restore so the user doesn't lose their text
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
}
|
||||
|
||||
// stop aborts the in-flight run for this conversation. running flips to false on
|
||||
// the next 1s poll once the backend unwinds the agent; the trigger queue is not
|
||||
// affected — the agent's next queued fire still starts.
|
||||
async function stop() {
|
||||
if (stopping) return;
|
||||
setStopping(true);
|
||||
try {
|
||||
await api.stopConversation(conv.id);
|
||||
} catch (e) {
|
||||
toast.error("停止失败:" + (e as Error).message);
|
||||
} finally {
|
||||
setStopping(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* header: which agent + live + token meta */}
|
||||
<div className="flex items-center gap-2 border-b px-4 py-2.5">
|
||||
<Bot className="text-muted-foreground size-4 shrink-0" />
|
||||
<span className="shrink-0 text-sm font-medium">{agent?.name ?? conv.agent_key}</span>
|
||||
<span className="text-muted-foreground shrink-0 font-mono text-xs">{conv.agent_key}</span>
|
||||
{agent && !agent.builtin && (
|
||||
<Badge variant="outline" className="shrink-0 px-1.5 py-0 text-[10px]">
|
||||
自定义
|
||||
</Badge>
|
||||
)}
|
||||
{agent?.description && (
|
||||
<span className="text-muted-foreground min-w-0 truncate text-xs">{agent.description}</span>
|
||||
)}
|
||||
{running && <LiveBadge />}
|
||||
<div className="text-muted-foreground ml-auto flex shrink-0 items-center gap-3 text-xs">
|
||||
{tokenTotal.turns > 0 && (
|
||||
<span title="agent 循环轮次(模型调用次数)" className="tabular-nums">
|
||||
{tokenTotal.turns} 轮
|
||||
</span>
|
||||
)}
|
||||
{tokenTotal.any && (
|
||||
<span title="input / cache(read) / output tokens" className="tabular-nums">
|
||||
input {fmtTokens(tokenTotal.i)} · cache {fmtTokens(tokenTotal.cr)} · output {fmtTokens(tokenTotal.o)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* messages */}
|
||||
<ScrollArea
|
||||
type="auto"
|
||||
className="min-h-0 min-w-0 flex-1 [&_[data-slot=scroll-area-viewport]>div]:block!"
|
||||
>
|
||||
<div className="min-w-0 max-w-full px-4 py-3" ref={contentRef}>
|
||||
{messages.length === 0 && !running ? (
|
||||
<div className="text-muted-foreground py-10 text-center text-sm">
|
||||
开始和「{agent?.name ?? conv.agent_key}」对话
|
||||
</div>
|
||||
) : (
|
||||
<Transcript activity={messages} live={running} chat fetchDetail={fetchDetail} />
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<Composer
|
||||
value={input}
|
||||
onChange={setInput}
|
||||
onSend={send}
|
||||
disabled={running}
|
||||
placeholder={running ? "Agent 正在回复…" : "输入消息,Enter 发送,Shift+Enter 换行"}
|
||||
running={running}
|
||||
onStop={stop}
|
||||
stopDisabled={stopping}
|
||||
/>
|
||||
<LLMProfileRow
|
||||
profiles={profiles}
|
||||
selected={currentProfileId}
|
||||
onChange={changeProfile}
|
||||
disabled={running || sending}
|
||||
rightSlot={<TodoPopover seq={latestTodoSeq} fetchDetail={fetchDetail} />}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ConversationItem is one row in the left rail — status-ish icon, title + agent
|
||||
// subtitle, inline rename, hover delete. Modeled on the console's SessionItem.
|
||||
function ConversationItem({
|
||||
conv,
|
||||
agent,
|
||||
active,
|
||||
renaming,
|
||||
renameText,
|
||||
onSelect,
|
||||
onStartRename,
|
||||
onRenameText,
|
||||
onCommitRename,
|
||||
onDelete,
|
||||
}: {
|
||||
conv: Conversation;
|
||||
agent?: Agent;
|
||||
active: boolean;
|
||||
renaming: boolean;
|
||||
renameText: string;
|
||||
onSelect: () => void;
|
||||
onStartRename: () => void;
|
||||
onRenameText: (v: string) => void;
|
||||
onCommitRename: () => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"group flex items-center gap-1 rounded-md pr-1 transition-colors",
|
||||
active ? "bg-accent text-accent-foreground" : "hover:bg-accent/50",
|
||||
)}
|
||||
>
|
||||
{renaming ? (
|
||||
<input
|
||||
autoFocus
|
||||
value={renameText}
|
||||
onChange={(e) => onRenameText(e.target.value)}
|
||||
onBlur={onCommitRename}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") onCommitRename();
|
||||
if (e.key === "Escape") onCommitRename();
|
||||
}}
|
||||
className="border-input bg-background min-w-0 flex-1 rounded-md border px-2 py-1 text-sm"
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelect}
|
||||
onDoubleClick={onStartRename}
|
||||
title="双击重命名"
|
||||
className="min-w-0 flex-1 rounded-md px-2 py-1.5 text-left"
|
||||
>
|
||||
<div className="truncate text-sm">{conv.title || "新对话"}</div>
|
||||
<div className="text-muted-foreground flex items-center gap-1 truncate text-[11px]">
|
||||
<Bot className="size-3 shrink-0" />
|
||||
{agent?.name ?? conv.agent_key}
|
||||
<span className="shrink-0">·</span>
|
||||
<span className="shrink-0">{new Date(conv.created_at).toLocaleDateString("zh-CN", { month: "numeric", day: "numeric", hour: "2-digit", minute: "2-digit" })}</span>
|
||||
<span className="shrink-0 opacity-60">#{conv.id}</span>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-muted-foreground hover:text-destructive shrink-0 opacity-0 transition-opacity group-hover:opacity-100"
|
||||
>
|
||||
<Trash2Icon className="size-3.5" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>删除对话「{conv.title || "新对话"}」?</AlertDialogTitle>
|
||||
<AlertDialogDescription>此操作不可撤销。</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={onDelete}>删除</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ChatPage() {
|
||||
const [agents, setAgents] = React.useState<Agent[]>([]);
|
||||
const [profiles, setProfiles] = React.useState<LLMProfile[]>([]);
|
||||
const [convs, setConvs] = React.useState<Conversation[]>([]);
|
||||
const [selectedId, setSelectedId] = React.useState<number | null>(null);
|
||||
const [renamingId, setRenamingId] = React.useState<number | null>(null);
|
||||
const [renameText, setRenameText] = React.useState("");
|
||||
|
||||
const reloadConvs = React.useCallback(() => {
|
||||
api.conversations().then(setConvs).catch(() => setConvs([]));
|
||||
}, []);
|
||||
React.useEffect(() => {
|
||||
api.agents().then(setAgents).catch(() => {});
|
||||
api.llmProfiles().then(setProfiles).catch(() => {});
|
||||
reloadConvs();
|
||||
}, [reloadConvs]);
|
||||
|
||||
const selected = convs.find((c) => c.id === selectedId) ?? null;
|
||||
// conversation agents: custom agents + conversational built-ins (role=assistant,
|
||||
// e.g. Auto / 渗透测试). The orchestration built-ins (goals/planner/mainagent/worker)
|
||||
// are task-specific and stay hidden from the chat page.
|
||||
const chatAgents = agents.filter((a) => !a.builtin || a.role === "assistant");
|
||||
|
||||
async function del(id: number) {
|
||||
try {
|
||||
await api.deleteConversation(id);
|
||||
if (selectedId === id) setSelectedId(null);
|
||||
reloadConvs();
|
||||
} catch (e) {
|
||||
toast.error("删除失败:" + (e as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
function startRename(c: Conversation) {
|
||||
setRenamingId(c.id);
|
||||
setRenameText(c.title || "");
|
||||
}
|
||||
async function commitRename() {
|
||||
const id = renamingId;
|
||||
const title = renameText.trim();
|
||||
setRenamingId(null);
|
||||
if (!id || !title) return;
|
||||
try {
|
||||
await api.renameConversation(id, title);
|
||||
reloadConvs();
|
||||
} catch (e) {
|
||||
toast.error("重命名失败:" + (e as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-content-padding="false"
|
||||
className="flex h-[calc(100svh-3rem)] flex-col overflow-hidden p-4 md:h-[calc(100svh-4rem)] md:p-6"
|
||||
>
|
||||
<div className="grid min-h-0 flex-1 grid-cols-[18rem_1fr] grid-rows-[minmax(0,1fr)] gap-4">
|
||||
{/* left: conversation list */}
|
||||
<div className="bg-card flex flex-col overflow-hidden rounded-lg border">
|
||||
<div className="border-b p-2">
|
||||
<Button size="sm" className="w-full" onClick={() => setSelectedId(null)}>
|
||||
<PlusIcon /> 新建对话
|
||||
</Button>
|
||||
</div>
|
||||
<ScrollArea type="auto" className="min-h-0 flex-1">
|
||||
<div className="flex flex-col gap-0.5 p-2">
|
||||
{convs.length === 0 && (
|
||||
<p className="text-muted-foreground px-2 py-6 text-center text-xs">暂无对话</p>
|
||||
)}
|
||||
{convs.map((c) => (
|
||||
<ConversationItem
|
||||
key={c.id}
|
||||
conv={c}
|
||||
agent={agents.find((a) => a.key === c.agent_key)}
|
||||
active={selectedId === c.id}
|
||||
renaming={renamingId === c.id}
|
||||
renameText={renameText}
|
||||
onSelect={() => setSelectedId(c.id)}
|
||||
onStartRename={() => startRename(c)}
|
||||
onRenameText={setRenameText}
|
||||
onCommitRename={commitRename}
|
||||
onDelete={() => del(c.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
{/* right: chat view */}
|
||||
<div className="bg-card flex min-w-0 flex-col overflow-hidden rounded-lg border">
|
||||
{selected ? (
|
||||
<ChatView
|
||||
key={selected.id}
|
||||
conv={selected}
|
||||
agents={agents}
|
||||
profiles={profiles}
|
||||
onTitleMaybeChanged={reloadConvs}
|
||||
onConvUpdated={reloadConvs}
|
||||
/>
|
||||
) : (
|
||||
<DraftChat
|
||||
agents={chatAgents}
|
||||
profiles={profiles}
|
||||
onStarted={(c) => {
|
||||
reloadConvs();
|
||||
setSelectedId(c.id);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,306 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
ChevronRightIcon,
|
||||
ShieldAlertIcon,
|
||||
ArrowUpRightIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
import { StatusBadge } from "@/components/status-badge";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { TablePagination } from "@/components/table-pagination";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { api } from "@/lib/api";
|
||||
import type { Finding, Severity } from "@/lib/types";
|
||||
|
||||
const SEVERITY_ORDER: Record<Severity, number> = { high: 3, medium: 2, low: 1 };
|
||||
|
||||
function fmtTime(ts: string) {
|
||||
return new Date(ts).toLocaleString("zh-CN", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
export default function FindingsPage() {
|
||||
const [severity, setSeverity] = React.useState<"all" | Severity>("all");
|
||||
const [vulnclass, setVulnclass] = React.useState<string>("all");
|
||||
const [sort, setSort] = React.useState<"severity" | "time">("severity");
|
||||
const [expanded, setExpanded] = React.useState<string | null>(null);
|
||||
const [findings, setFindings] = React.useState<Finding[]>([]);
|
||||
const [page, setPage] = React.useState(1);
|
||||
const [pageSize, setPageSize] = React.useState(20);
|
||||
|
||||
React.useEffect(() => {
|
||||
let alive = true;
|
||||
const load = () => {
|
||||
api
|
||||
.findings()
|
||||
.then((fs) => {
|
||||
if (alive) setFindings(fs);
|
||||
})
|
||||
.catch(() => {});
|
||||
};
|
||||
load();
|
||||
const t = setInterval(load, 5000);
|
||||
return () => {
|
||||
alive = false;
|
||||
clearInterval(t);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const vulnclasses = React.useMemo(
|
||||
() => Array.from(new Set(findings.map((f) => f.vulnclass))).sort(),
|
||||
[findings],
|
||||
);
|
||||
|
||||
const counts = React.useMemo(() => {
|
||||
const c = { high: 0, medium: 0, low: 0 };
|
||||
for (const f of findings) c[f.severity]++;
|
||||
const tasks = new Set(findings.map((f) => f.task_id)).size;
|
||||
return { ...c, total: findings.length, tasks };
|
||||
}, [findings]);
|
||||
|
||||
const rows = React.useMemo(() => {
|
||||
let list = findings.slice();
|
||||
if (severity !== "all") list = list.filter((f) => f.severity === severity);
|
||||
if (vulnclass !== "all")
|
||||
list = list.filter((f) => f.vulnclass === vulnclass);
|
||||
list.sort((a, b) => {
|
||||
if (sort === "severity") {
|
||||
const d = SEVERITY_ORDER[b.severity] - SEVERITY_ORDER[a.severity];
|
||||
if (d !== 0) return d;
|
||||
}
|
||||
return new Date(b.ts).getTime() - new Date(a.ts).getTime();
|
||||
});
|
||||
return list;
|
||||
}, [findings, severity, vulnclass, sort]);
|
||||
|
||||
// reset to page 1 whenever filters change
|
||||
React.useEffect(() => { setPage(1); }, [severity, vulnclass, sort]);
|
||||
|
||||
const paginated = React.useMemo(
|
||||
() => rows.slice((page - 1) * pageSize, page * pageSize),
|
||||
[rows, page, pageSize],
|
||||
);
|
||||
|
||||
const statCards: { label: string; value: number; tone?: string }[] = [
|
||||
{ label: "发现总数", value: counts.total },
|
||||
{ label: "高危", value: counts.high, tone: "text-red-500" },
|
||||
{ label: "中危", value: counts.medium, tone: "text-amber-500" },
|
||||
{ label: "低危", value: counts.low, tone: "text-slate-500" },
|
||||
{ label: "涉及任务", value: counts.tasks },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col gap-4 md:gap-6">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold tracking-tight">发现</h1>
|
||||
<p className="text-muted-foreground text-sm">跨任务漏洞汇总</p>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col gap-4 md:gap-6">
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-5">
|
||||
{statCards.map((s) => (
|
||||
<Card key={s.label} className="gap-1 py-4">
|
||||
<CardHeader className="px-4">
|
||||
<CardDescription>{s.label}</CardDescription>
|
||||
<CardTitle
|
||||
className={cn("text-2xl tabular-nums", s.tone)}
|
||||
>
|
||||
{s.value}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="flex items-center gap-1 rounded-md border p-0.5">
|
||||
{(
|
||||
[
|
||||
["all", "全部"],
|
||||
["high", "高危"],
|
||||
["medium", "中危"],
|
||||
["low", "低危"],
|
||||
] as const
|
||||
).map(([val, label]) => (
|
||||
<button
|
||||
key={val}
|
||||
onClick={() => setSeverity(val)}
|
||||
className={cn(
|
||||
"rounded px-2.5 py-1 text-xs font-medium transition-colors",
|
||||
severity === val
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "text-muted-foreground hover:bg-muted",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Select value={vulnclass} onValueChange={setVulnclass}>
|
||||
<SelectTrigger size="sm" className="w-40">
|
||||
<SelectValue placeholder="漏洞类型" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部类型</SelectItem>
|
||||
{vulnclasses.map((vc) => (
|
||||
<SelectItem key={vc} value={vc}>
|
||||
{vc}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
value={sort}
|
||||
onValueChange={(v) => setSort(v as "severity" | "time")}
|
||||
>
|
||||
<SelectTrigger size="sm" className="w-36">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="severity">按严重度</SelectItem>
|
||||
<SelectItem value="time">按时间</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<span className="ml-auto text-xs text-muted-foreground tabular-nums">
|
||||
共 {rows.length} 条
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Card className="py-0">
|
||||
<CardContent className="px-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-8" />
|
||||
<TableHead className="w-20">严重度</TableHead>
|
||||
<TableHead className="w-28">漏洞类型</TableHead>
|
||||
<TableHead>摘要</TableHead>
|
||||
<TableHead className="w-36 max-w-[9rem]">所属任务</TableHead>
|
||||
<TableHead className="w-32">时间</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{paginated.map((f) => {
|
||||
const open = expanded === f.id;
|
||||
return (
|
||||
<React.Fragment key={f.id}>
|
||||
<TableRow
|
||||
className="cursor-pointer"
|
||||
onClick={() => setExpanded(open ? null : f.id)}
|
||||
>
|
||||
<TableCell>
|
||||
<ChevronRightIcon
|
||||
className={cn(
|
||||
"size-4 text-muted-foreground transition-transform",
|
||||
open && "rotate-90",
|
||||
)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge domain="severity" value={f.severity} dot />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs">
|
||||
{f.vulnclass}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-md">
|
||||
<span className="line-clamp-1">{f.summary}</span>
|
||||
</TableCell>
|
||||
<TableCell className="w-36 max-w-[9rem]">
|
||||
{f.task_id ? (
|
||||
<Link
|
||||
href={`/function/tasks/detail?id=${f.task_id}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="inline-flex max-w-full items-center gap-1 text-primary hover:underline"
|
||||
title={f.task_description}
|
||||
>
|
||||
<span className="truncate">{f.task_description}</span>
|
||||
<ArrowUpRightIcon className="size-3 shrink-0" />
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground tabular-nums">
|
||||
{fmtTime(f.ts)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{open && (
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableCell colSpan={6} className="bg-muted/30">
|
||||
<div className="flex flex-col gap-2 px-2 py-1">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<ShieldAlertIcon className="size-3.5" />
|
||||
证据 · {f.id}
|
||||
{f.param_id && (
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 font-mono">
|
||||
{f.param_id}
|
||||
</code>
|
||||
)}
|
||||
</div>
|
||||
<pre className="overflow-x-auto rounded-md bg-muted px-3 py-2 font-mono text-xs whitespace-pre-wrap">
|
||||
{f.evidence}
|
||||
</pre>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
{rows.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={6}
|
||||
className="py-12 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
没有匹配的发现。
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<TablePagination
|
||||
page={page}
|
||||
pageSize={pageSize}
|
||||
total={rows.length}
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={setPageSize}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,486 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { AlertCircleIcon, CheckCircle2Icon, DownloadIcon, PlugZapIcon, RefreshCwIcon, SearchIcon } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { api } from "@/lib/api";
|
||||
import type { SSProject, SSTask } from "@/lib/types";
|
||||
|
||||
type SSStatus = {
|
||||
exists: boolean;
|
||||
configured: boolean;
|
||||
enabled: boolean;
|
||||
reachable: boolean;
|
||||
url?: string;
|
||||
tools: string[];
|
||||
};
|
||||
|
||||
type Dimension = "project" | "task";
|
||||
|
||||
const ASSET_TYPES: { key: string; label: string }[] = [
|
||||
{ key: "subdomain", label: "子域名" },
|
||||
{ key: "service", label: "服务" },
|
||||
{ key: "app", label: "App" },
|
||||
];
|
||||
|
||||
export default function AssetSyncPage() {
|
||||
return (
|
||||
<div className="p-4 md:p-6">
|
||||
<div className="mb-4">
|
||||
<h1 className="font-semibold text-xl">资产同步</h1>
|
||||
<p className="text-muted-foreground text-sm">从外部数据源同步资产入库</p>
|
||||
</div>
|
||||
<Tabs defaultValue="scopesentry">
|
||||
<TabsList>
|
||||
<TabsTrigger value="scopesentry">ScopeSentry</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="scopesentry" className="mt-4">
|
||||
<ScopeSentryPanel />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ScopeSentryPanel() {
|
||||
const [status, setStatus] = React.useState<SSStatus | null>(null);
|
||||
const [loadingStatus, setLoadingStatus] = React.useState(true);
|
||||
|
||||
const loadStatus = React.useCallback(() => {
|
||||
setLoadingStatus(true);
|
||||
api
|
||||
.ssStatus()
|
||||
.then(setStatus)
|
||||
.catch((e) => toast.error(`读取数据源状态失败:${e.message}`))
|
||||
.finally(() => setLoadingStatus(false));
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
loadStatus();
|
||||
}, [loadStatus]);
|
||||
|
||||
const ready = !!status && status.exists && status.configured && status.enabled;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<DataSourceCard status={status} loading={loadingStatus} onChanged={loadStatus} />
|
||||
{ready ? (
|
||||
<SyncWorkbench />
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center text-muted-foreground text-sm">
|
||||
数据源就绪后即可选择项目 / 任务进行同步。
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 数据源状态卡 ─────────────────────────────────────────────────────────────
|
||||
|
||||
function DataSourceCard({
|
||||
status,
|
||||
loading,
|
||||
onChanged,
|
||||
}: {
|
||||
status: SSStatus | null;
|
||||
loading: boolean;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const [url, setUrl] = React.useState("");
|
||||
const [apiKey, setApiKey] = React.useState("");
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (status?.url) setUrl(status.url);
|
||||
}, [status?.url]);
|
||||
|
||||
const create = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.ssDatasource({});
|
||||
toast.success("已创建 ScopeSentry 数据源,请填写地址与密钥");
|
||||
onChanged();
|
||||
} catch (e) {
|
||||
toast.error(`创建失败:${(e as Error).message}`);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (!url.trim()) return toast.error("请填写 MCP 地址");
|
||||
setBusy(true);
|
||||
try {
|
||||
const r = await api.ssDatasource({ url: url.trim(), api_key: apiKey.trim() });
|
||||
toast.success(r.enabled ? "已保存并启用数据源" : "已保存(尚未满足启用条件)");
|
||||
setApiKey("");
|
||||
onChanged();
|
||||
} catch (e) {
|
||||
toast.error(`保存失败:${(e as Error).message}`);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<PlugZapIcon className="size-4" /> 数据源状态
|
||||
<StatusBadge status={status} loading={loading} />
|
||||
</CardTitle>
|
||||
<Button variant="ghost" size="sm" onClick={onChanged} disabled={loading}>
|
||||
<RefreshCwIcon className={loading ? "size-4 animate-spin" : "size-4"} /> 刷新
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{!status?.exists ? (
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
尚未创建 ScopeSentry 数据源。创建后会新增一个占位 MCP(地址/密钥为空、未启用)。
|
||||
</p>
|
||||
<Button onClick={create} disabled={busy}>
|
||||
创建数据源
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{!status.configured && (
|
||||
<p className="text-amber-600 text-sm dark:text-amber-500">
|
||||
数据源已创建但未配置,请填写 MCP 地址与 API Key 后启用。
|
||||
</p>
|
||||
)}
|
||||
{status.configured && !status.enabled && (
|
||||
<p className="text-amber-600 text-sm dark:text-amber-500">数据源已配置但未启用,保存后将自动启用。</p>
|
||||
)}
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label>MCP 地址</Label>
|
||||
<Input placeholder="http://<主机>:8082/mcp" value={url} onChange={(e) => setUrl(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>API Key(X-API-Key,留空保留原值)</Label>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="ssk_..."
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button onClick={save} disabled={busy}>
|
||||
保存并启用
|
||||
</Button>
|
||||
{status.enabled && status.tools.length > 0 && (
|
||||
<span className="text-muted-foreground text-xs">已发现 {status.tools.length} 个工具</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status, loading }: { status: SSStatus | null; loading: boolean }) {
|
||||
if (loading || !status) return <Badge variant="secondary">检测中…</Badge>;
|
||||
if (!status.exists) return <Badge variant="destructive">未创建</Badge>;
|
||||
if (!status.configured) return <Badge variant="outline">未配置</Badge>;
|
||||
if (!status.enabled) return <Badge variant="outline">未启用</Badge>;
|
||||
if (status.reachable)
|
||||
return (
|
||||
<Badge className="bg-emerald-600 hover:bg-emerald-600">
|
||||
<CheckCircle2Icon className="mr-1 size-3" /> 已连接
|
||||
</Badge>
|
||||
);
|
||||
return (
|
||||
<Badge variant="destructive">
|
||||
<AlertCircleIcon className="mr-1 size-3" /> 不可达
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 同步工作区(项目 / 任务维度)────────────────────────────────────────────────
|
||||
|
||||
function SyncWorkbench() {
|
||||
const [dimension, setDimension] = React.useState<Dimension>("project");
|
||||
const [assetTypes, setAssetTypes] = React.useState<Record<string, boolean>>({
|
||||
subdomain: true,
|
||||
service: true,
|
||||
app: true,
|
||||
});
|
||||
const [createCompany, setCreateCompany] = React.useState(true);
|
||||
|
||||
const [projects, setProjects] = React.useState<SSProject[]>([]);
|
||||
const [tasks, setTasks] = React.useState<SSTask[]>([]);
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const [search, setSearch] = React.useState("");
|
||||
const [page, setPage] = React.useState(1);
|
||||
const [selected, setSelected] = React.useState<Set<string>>(new Set());
|
||||
|
||||
const [syncing, setSyncing] = React.useState(false);
|
||||
const [result, setResult] = React.useState<Awaited<ReturnType<typeof api.ssSync>> | null>(null);
|
||||
|
||||
const load = React.useCallback(() => {
|
||||
setLoading(true);
|
||||
setSelected(new Set());
|
||||
const fn =
|
||||
dimension === "project"
|
||||
? api.ssProjects(page, 50, search).then((r) => setProjects(r.projects))
|
||||
: api.ssTasks(page, 50, search).then(setTasks);
|
||||
fn.catch((e) => toast.error(`加载列表失败:${e.message}`)).finally(() => setLoading(false));
|
||||
}, [dimension, page, search]);
|
||||
|
||||
React.useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const rows = dimension === "project" ? projects : tasks;
|
||||
const idOf = (row: SSProject | SSTask) => (dimension === "project" ? (row as SSProject).id : (row as SSTask).name);
|
||||
|
||||
const toggle = (id: string) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
const toggleAll = () => {
|
||||
setSelected((prev) => (prev.size === rows.length ? new Set() : new Set(rows.map(idOf))));
|
||||
};
|
||||
|
||||
const chosenTypes = ASSET_TYPES.filter((t) => assetTypes[t.key]).map((t) => t.key);
|
||||
|
||||
const runSync = async () => {
|
||||
if (selected.size === 0) return toast.error(`请至少选择一个${dimension === "project" ? "项目" : "任务"}`);
|
||||
if (chosenTypes.length === 0) return toast.error("请至少选择一种资产类型");
|
||||
setSyncing(true);
|
||||
setResult(null);
|
||||
try {
|
||||
const r = await api.ssSync({
|
||||
dimension,
|
||||
targets: [...selected],
|
||||
asset_types: chosenTypes,
|
||||
create_company: dimension === "project" ? createCompany : false,
|
||||
});
|
||||
setResult(r);
|
||||
const total = Object.values(r.synced ?? {}).reduce((a, b) => a + b, 0);
|
||||
toast.success(`同步完成,共入库 ${total} 条资产`);
|
||||
} catch (e) {
|
||||
toast.error(`同步失败:${(e as Error).message}`);
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const renderRows = () => {
|
||||
if (loading) {
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="py-8 text-center text-muted-foreground text-sm">
|
||||
加载中…
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="py-8 text-center text-muted-foreground text-sm">
|
||||
无数据
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
if (dimension === "project") {
|
||||
return projects.map((p) => (
|
||||
<TableRow key={p.id} className="cursor-pointer" onClick={() => toggle(p.id)}>
|
||||
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox checked={selected.has(p.id)} onCheckedChange={() => toggle(p.id)} />
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">{p.name}</TableCell>
|
||||
<TableCell>{p.tag ? <Badge variant="secondary">{p.tag}</Badge> : "—"}</TableCell>
|
||||
<TableCell className="text-right">{p.AssetCount ?? 0}</TableCell>
|
||||
</TableRow>
|
||||
));
|
||||
}
|
||||
return tasks.map((t) => (
|
||||
<TableRow key={t.id} className="cursor-pointer" onClick={() => toggle(t.name)}>
|
||||
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox checked={selected.has(t.name)} onCheckedChange={() => toggle(t.name)} />
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">{t.name}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={t.progress === 100 ? "secondary" : "outline"}>
|
||||
{t.progress != null ? `${t.progress}%` : "—"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground text-xs">{t.endTime || t.creatTime || "—"}</TableCell>
|
||||
</TableRow>
|
||||
));
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">选择数据同步</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* 维度切换 */}
|
||||
<Tabs
|
||||
value={dimension}
|
||||
onValueChange={(v) => {
|
||||
setDimension(v as Dimension);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<TabsList>
|
||||
<TabsTrigger value="project">项目维度</TabsTrigger>
|
||||
<TabsTrigger value="task">任务维度</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
{/* 资产类型 + 选项 */}
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<span className="font-medium text-sm">同步资产:</span>
|
||||
{ASSET_TYPES.map((t) => (
|
||||
<label key={t.key} htmlFor={`at-${t.key}`} className="flex items-center gap-1.5 text-sm">
|
||||
<Checkbox
|
||||
id={`at-${t.key}`}
|
||||
checked={!!assetTypes[t.key]}
|
||||
onCheckedChange={(c) => setAssetTypes((prev) => ({ ...prev, [t.key]: !!c }))}
|
||||
/>
|
||||
{t.label}
|
||||
</label>
|
||||
))}
|
||||
{dimension === "project" && (
|
||||
<label htmlFor="create-company" className="flex items-center gap-1.5 text-sm">
|
||||
<Checkbox id="create-company" checked={createCompany} onCheckedChange={(c) => setCreateCompany(!!c)} />
|
||||
按项目建立企业并写入资产范围
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 搜索 + 操作 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative max-w-xs flex-1">
|
||||
<SearchIcon className="absolute top-1/2 left-2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
className="pl-8"
|
||||
placeholder={dimension === "project" ? "搜索项目名" : "搜索任务名"}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
setPage(1);
|
||||
load();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={load} disabled={loading}>
|
||||
<RefreshCwIcon className={loading ? "size-4 animate-spin" : "size-4"} />
|
||||
</Button>
|
||||
<div className="flex-1" />
|
||||
<span className="text-muted-foreground text-xs">已选 {selected.size}</span>
|
||||
<Button onClick={runSync} disabled={syncing || selected.size === 0}>
|
||||
<DownloadIcon className={syncing ? "size-4 animate-pulse" : "size-4"} /> 同步选中
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 列表 */}
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-10">
|
||||
<Checkbox checked={rows.length > 0 && selected.size === rows.length} onCheckedChange={toggleAll} />
|
||||
</TableHead>
|
||||
<TableHead>{dimension === "project" ? "项目名" : "任务名"}</TableHead>
|
||||
{dimension === "project" ? (
|
||||
<>
|
||||
<TableHead>标签</TableHead>
|
||||
<TableHead className="text-right">资产数</TableHead>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>时间</TableHead>
|
||||
</>
|
||||
)}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>{renderRows()}</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* 分页 */}
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button variant="outline" size="sm" disabled={page <= 1 || loading} onClick={() => setPage((p) => p - 1)}>
|
||||
上一页
|
||||
</Button>
|
||||
<span className="text-muted-foreground text-xs">第 {page} 页</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={rows.length < 50 || loading}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 结果 */}
|
||||
{result && <SyncResult result={result} />}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function SyncResult({ result }: { result: Awaited<ReturnType<typeof api.ssSync>> }) {
|
||||
const synced = result.synced ?? {};
|
||||
const labels: Record<string, string> = { subdomain: "子域名", service: "服务", app: "App", ip: "IP" };
|
||||
return (
|
||||
<div className="space-y-2 rounded-md border bg-muted/40 p-3 text-sm">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{Object.entries(synced).map(([k, v]) => (
|
||||
<Badge key={k} variant="secondary">
|
||||
{labels[k] ?? k}: {v}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
{result.companies && result.companies.length > 0 && (
|
||||
<p className="text-muted-foreground">新建/更新企业:{result.companies.join("、")}</p>
|
||||
)}
|
||||
{result.warnings && result.warnings.length > 0 && (
|
||||
<ul className="list-inside list-disc text-amber-600 dark:text-amber-500">
|
||||
{result.warnings.map((wm) => (
|
||||
<li key={wm}>{wm}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{result.errors && result.errors.length > 0 && (
|
||||
<ul className="list-inside list-disc text-destructive">
|
||||
{result.errors.slice(0, 20).map((em) => (
|
||||
<li key={em}>{em}</li>
|
||||
))}
|
||||
{result.errors.length > 20 && <li>…共 {result.errors.length} 条错误</li>}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import {
|
||||
GlobeIcon,
|
||||
NetworkIcon,
|
||||
LayoutTemplateIcon,
|
||||
LinkIcon,
|
||||
KeyRoundIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Tabs, TabsContent } from "@/components/ui/tabs";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { api } from "@/lib/api";
|
||||
import type { Asset } from "@/lib/types";
|
||||
|
||||
const PAGE_SIZES = [25, 50, 100, 200];
|
||||
|
||||
const METHOD_COLOR: Record<string, string> = {
|
||||
GET: "bg-emerald-100 text-emerald-700",
|
||||
POST: "bg-blue-100 text-blue-700",
|
||||
PUT: "bg-amber-100 text-amber-700",
|
||||
PATCH: "bg-orange-100 text-orange-700",
|
||||
DELETE: "bg-red-100 text-red-700",
|
||||
HEAD: "bg-purple-100 text-purple-700",
|
||||
OPTIONS: "bg-slate-100 text-slate-600",
|
||||
};
|
||||
|
||||
function MethodBadge({ method }: { method: string }) {
|
||||
const m = method.toUpperCase();
|
||||
return (
|
||||
<span className={cn("inline-block rounded px-1.5 py-0.5 font-mono text-[10px] font-semibold leading-none", METHOD_COLOR[m] ?? "bg-muted text-muted-foreground")}>
|
||||
{m || "—"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function statusTone(code: number) {
|
||||
if (code >= 500) return "text-red-500";
|
||||
if (code >= 400) return "text-amber-500";
|
||||
if (code >= 300) return "text-blue-500";
|
||||
if (code >= 200) return "text-emerald-500";
|
||||
return "text-muted-foreground";
|
||||
}
|
||||
|
||||
function fmtBytes(n?: number | null) {
|
||||
if (!n || !Number.isFinite(n) || n <= 0) return "—";
|
||||
const u = ["B", "KB", "MB"];
|
||||
const i = Math.min(Math.floor(Math.log(n) / Math.log(1024)), u.length - 1);
|
||||
return `${i === 0 ? n : (n / 1024 ** i).toFixed(1)} ${u[i]}`;
|
||||
}
|
||||
|
||||
function Chips({ items, mono }: { items: string[]; mono?: boolean }) {
|
||||
const clean = items.filter(Boolean);
|
||||
if (clean.length === 0) return <span className="text-xs text-muted-foreground">—</span>;
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{clean.map((s, i) => (
|
||||
<Badge key={i} variant="outline" className={cn("text-[10px]", mono && "font-mono")}>{s}</Badge>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AssetCard({
|
||||
cols, total, page, size, onSize, onPage, children,
|
||||
}: {
|
||||
cols: string[];
|
||||
total: number;
|
||||
page: number;
|
||||
size: number;
|
||||
onSize: (v: number) => void;
|
||||
onPage: (fn: (p: number) => number) => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const rows = React.Children.toArray(children);
|
||||
const pageCount = Math.max(1, Math.ceil(total / size));
|
||||
const start = total === 0 ? 0 : page * size + 1;
|
||||
const end = page * size + rows.length;
|
||||
return (
|
||||
<Card className="flex min-h-0 flex-1 flex-col overflow-hidden py-0">
|
||||
<div className="min-h-0 flex-1 overflow-auto scrollbar-thin scrollbar-track-transparent">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10 bg-card">
|
||||
<TableRow>
|
||||
{cols.map((c) => <TableHead key={c}>{c}</TableHead>)}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.length > 0 ? rows : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={cols.length} className="py-12 text-center text-sm text-muted-foreground">
|
||||
暂无数据
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
{total > 0 && (
|
||||
<div className="flex shrink-0 items-center gap-2 border-t px-3 py-1.5 text-xs text-muted-foreground">
|
||||
<Select value={String(size)} onValueChange={(v) => onSize(Number(v))}>
|
||||
<SelectTrigger size="sm" className="h-7 w-24"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{PAGE_SIZES.map((n) => <SelectItem key={n} value={String(n)}>{n} / 页</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span className="tabular-nums">{start}–{end} / {total}</span>
|
||||
{pageCount > 1 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="icon" className="size-7" disabled={page <= 0} onClick={() => onPage((p) => Math.max(0, p - 1))}>
|
||||
<ChevronLeftIcon />
|
||||
</Button>
|
||||
<span className="tabular-nums">{page + 1} / {pageCount}</span>
|
||||
<Button variant="outline" size="icon" className="size-7" disabled={page + 1 >= pageCount} onClick={() => onPage((p) => Math.min(pageCount - 1, p + 1))}>
|
||||
<ChevronRightIcon />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const TABS: { key: string; label: string; icon: LucideIcon }[] = [
|
||||
{ key: "root_domain", label: "根域名", icon: GlobeIcon },
|
||||
{ key: "ip", label: "IP", icon: NetworkIcon },
|
||||
{ key: "subdomain", label: "子域名", icon: GlobeIcon },
|
||||
{ key: "service", label: "服务", icon: LayoutTemplateIcon },
|
||||
{ key: "endpoint", label: "接口", icon: LinkIcon },
|
||||
];
|
||||
|
||||
export function AssetsTab({ taskId }: { taskId: string }) {
|
||||
const [assets, setAssets] = React.useState<Asset[]>([]);
|
||||
const [loaded, setLoaded] = React.useState(false);
|
||||
const [tab, setTab] = React.useState("root_domain");
|
||||
const [page, setPage] = React.useState(0);
|
||||
const [size, setSize] = React.useState(50);
|
||||
|
||||
React.useEffect(() => setPage(0), [tab, size]);
|
||||
|
||||
React.useEffect(() => {
|
||||
let alive = true;
|
||||
const load = async () => {
|
||||
try {
|
||||
const results = await Promise.all(
|
||||
TABS.map((t) => api.taskAssets(taskId, t.key)),
|
||||
);
|
||||
if (!alive) return;
|
||||
setAssets(results.flat());
|
||||
setLoaded(true);
|
||||
} catch {
|
||||
if (alive) setLoaded(true);
|
||||
}
|
||||
};
|
||||
load();
|
||||
const timer = setInterval(load, 10_000);
|
||||
return () => { alive = false; clearInterval(timer); };
|
||||
}, [taskId]);
|
||||
|
||||
const byType = React.useMemo(() => {
|
||||
const m: Record<string, Asset[]> = { root_domain: [], ip: [], subdomain: [], service: [], endpoint: [] };
|
||||
for (const a of assets) {
|
||||
if (a.type in m) m[a.type].push(a);
|
||||
}
|
||||
return m;
|
||||
}, [assets]);
|
||||
|
||||
const counts: Record<string, number> = {
|
||||
root_domain: byType.root_domain.length,
|
||||
ip: byType.ip.length,
|
||||
subdomain: byType.subdomain.length,
|
||||
service: byType.service.length,
|
||||
endpoint: byType.endpoint.length,
|
||||
};
|
||||
|
||||
const totalAll = assets.length;
|
||||
|
||||
if (!loaded) {
|
||||
return <div className="py-20 text-center text-sm text-muted-foreground">加载中…</div>;
|
||||
}
|
||||
if (totalAll === 0) {
|
||||
return (
|
||||
<div className="py-20 text-center text-sm text-muted-foreground">
|
||||
该任务暂无涉及资产
|
||||
<p className="mt-1 text-xs">资产在任务执行过程中由 agent 发现并记录</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const cur = byType[tab] ?? [];
|
||||
const slice = cur.slice(page * size, page * size + size);
|
||||
|
||||
return (
|
||||
<Tabs orientation="vertical" value={tab} onValueChange={setTab} className="items-start gap-4">
|
||||
{/* left nav */}
|
||||
<div className="w-36 shrink-0 rounded-lg border bg-card p-1 shadow-sm">
|
||||
{TABS.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
onClick={() => setTab(t.key)}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-sm transition-colors",
|
||||
tab === t.key
|
||||
? "bg-accent text-accent-foreground font-medium"
|
||||
: "text-muted-foreground hover:bg-accent/60 hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<t.icon className="size-3.5 shrink-0" />
|
||||
<span className="flex-1 text-left">{t.label}</span>
|
||||
<span className="tabular-nums text-xs opacity-70">{counts[t.key] ?? 0}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* right content */}
|
||||
<div className="min-w-0 flex-1 flex flex-col gap-4">
|
||||
|
||||
<TabsContent value="root_domain" className="mt-0 flex min-h-0 flex-1 flex-col">
|
||||
<AssetCard cols={["域名", "ICP 备案"]} total={cur.length} page={page} size={size} onSize={setSize} onPage={setPage}>
|
||||
{slice.map((a) => (
|
||||
<TableRow key={a.id}>
|
||||
<TableCell className="font-mono text-xs font-medium">{a.domain}</TableCell>
|
||||
<TableCell className="text-xs">{a.icp || "—"}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</AssetCard>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="ip" className="mt-0 flex min-h-0 flex-1 flex-col">
|
||||
<AssetCard cols={["IP", "C段", "绑定域名", "开放端口"]} total={cur.length} page={page} size={size} onSize={setSize} onPage={setPage}>
|
||||
{slice.map((a) => (
|
||||
<TableRow key={a.id}>
|
||||
<TableCell className="font-mono text-xs font-medium">{a.ip}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{a.c_segment || "—"}</TableCell>
|
||||
<TableCell><Chips items={a.bound_domains ?? []} mono /></TableCell>
|
||||
<TableCell>
|
||||
<Chips
|
||||
items={(a.open_ports ?? []).map((p) =>
|
||||
p.service ? `${p.port}/${p.service}` : String(p.port),
|
||||
)}
|
||||
mono
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</AssetCard>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="subdomain" className="mt-0 flex min-h-0 flex-1 flex-col">
|
||||
<AssetCard cols={["域名", "根域名", "解析类型", "解析值"]} total={cur.length} page={page} size={size} onSize={setSize} onPage={setPage}>
|
||||
{slice.map((a) => (
|
||||
<TableRow key={a.id}>
|
||||
<TableCell className="font-mono text-xs font-medium">{a.domain}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{a.root_domain || "—"}</TableCell>
|
||||
<TableCell className="text-xs">{a.record_type || "—"}</TableCell>
|
||||
<TableCell className="max-w-xs truncate font-mono text-xs">{(Array.isArray(a.record_value) ? a.record_value.join(", ") : a.record_value) || "—"}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</AssetCard>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="service" className="mt-0 flex min-h-0 flex-1 flex-col">
|
||||
<AssetCard
|
||||
cols={["地址 / 服务", "状态码", "标题", "响应长度", "指纹", "认证"]}
|
||||
total={cur.length}
|
||||
page={page}
|
||||
size={size}
|
||||
onSize={setSize}
|
||||
onPage={setPage}
|
||||
>
|
||||
{slice.map((a) => {
|
||||
const isHttp = a.service_type === "http";
|
||||
const addr = isHttp
|
||||
? (a.url || "")
|
||||
: (a.service_name || [a.ip || a.domain, a.port].filter(Boolean).join(":"));
|
||||
return (
|
||||
<TableRow key={a.id}>
|
||||
<TableCell className="max-w-xs truncate font-mono text-xs" title={addr}>{addr || "—"}</TableCell>
|
||||
<TableCell>
|
||||
{a.status_code != null ? (
|
||||
<span className={cn("font-mono text-xs font-semibold tabular-nums", statusTone(a.status_code))}>
|
||||
{a.status_code}
|
||||
</span>
|
||||
) : "—"}
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[12rem] truncate text-xs">{a.page_title || "—"}</TableCell>
|
||||
<TableCell className="text-xs tabular-nums">{fmtBytes(a.content_length)}</TableCell>
|
||||
<TableCell><Chips items={a.technologies ?? []} /></TableCell>
|
||||
<TableCell>
|
||||
{(a.auth ?? []).length === 0 ? (
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
) : (
|
||||
(a.auth ?? []).map((authItem, i) => {
|
||||
const item = authItem as Record<string, string>;
|
||||
return (
|
||||
<span key={i} className="inline-flex items-center gap-1 text-[11px]">
|
||||
<KeyRoundIcon className="size-3 text-muted-foreground" />
|
||||
<span className="font-mono">{item.type || item.username || "认证"}</span>
|
||||
</span>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</AssetCard>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="endpoint" className="mt-0 flex min-h-0 flex-1 flex-col">
|
||||
<AssetCard cols={["方法", "完整地址", "参数"]} total={cur.length} page={page} size={size} onSize={setSize} onPage={setPage}>
|
||||
{slice.map((a) => (
|
||||
<TableRow key={a.id}>
|
||||
<TableCell className="w-16"><MethodBadge method={a.method || ""} /></TableCell>
|
||||
<TableCell className="max-w-sm truncate font-mono text-xs" title={a.url}>{a.url || "—"}</TableCell>
|
||||
<TableCell>
|
||||
<Chips
|
||||
items={(a.params ?? []).map((p) => {
|
||||
const item = p as Record<string, string>;
|
||||
return item.name ? `${item.name}(${item.location || "?"})` : "?";
|
||||
})}
|
||||
mono
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</AssetCard>
|
||||
</TabsContent>
|
||||
|
||||
</div>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { ChevronRightIcon } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { StatusBadge } from "@/components/status-badge";
|
||||
import { api } from "@/lib/api";
|
||||
import type { Finding } from "@/lib/types";
|
||||
|
||||
function Row({ f }: { f: Finding }) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
return (
|
||||
<div className="border-b last:border-b-0">
|
||||
<button
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className="flex w-full items-center gap-3 px-4 py-3 text-left text-sm hover:bg-accent/40"
|
||||
>
|
||||
<ChevronRightIcon
|
||||
className={cn(
|
||||
"size-4 shrink-0 text-muted-foreground transition-transform",
|
||||
open && "rotate-90",
|
||||
)}
|
||||
/>
|
||||
<StatusBadge domain="severity" value={f.severity} dot />
|
||||
<span className="w-20 shrink-0 font-medium">{f.vulnclass}</span>
|
||||
<span className="min-w-0 flex-1 truncate text-muted-foreground">
|
||||
{f.summary}
|
||||
</span>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
{new Date(f.ts).toLocaleString("zh-CN")}
|
||||
</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="bg-muted/30 px-4 pb-4 pl-11">
|
||||
<div className="mb-1 text-xs font-medium text-muted-foreground">
|
||||
证据 / PoC
|
||||
</div>
|
||||
<pre className="overflow-auto rounded-md border bg-background p-3 font-mono text-xs whitespace-pre-wrap">
|
||||
{f.evidence}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FindingsTab({ taskId }: { taskId: string }) {
|
||||
const [findings, setFindings] = React.useState<Finding[]>([]);
|
||||
|
||||
React.useEffect(() => {
|
||||
let active = true;
|
||||
const load = () => {
|
||||
api
|
||||
.findings(taskId)
|
||||
.then((fs) => {
|
||||
if (active) setFindings(fs);
|
||||
})
|
||||
.catch(() => {});
|
||||
};
|
||||
load();
|
||||
const t = setInterval(load, 3000);
|
||||
return () => {
|
||||
active = false;
|
||||
clearInterval(t);
|
||||
};
|
||||
}, [taskId]);
|
||||
|
||||
const items = findings
|
||||
.filter((f) => f.task_id === taskId)
|
||||
.sort((a, b) => {
|
||||
const order = { high: 0, medium: 1, low: 2 };
|
||||
return order[a.severity] - order[b.severity];
|
||||
});
|
||||
|
||||
return (
|
||||
<Card className="overflow-hidden py-0">
|
||||
<CardContent className="px-0">
|
||||
{items.map((f) => (
|
||||
<Row key={f.id} f={f} />
|
||||
))}
|
||||
{items.length === 0 && (
|
||||
<p className="px-4 py-8 text-center text-sm text-muted-foreground">
|
||||
本任务暂无确认发现。
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,633 @@
|
||||
"use client";
|
||||
"use no memo";
|
||||
|
||||
import * as React from "react";
|
||||
import {
|
||||
Background,
|
||||
BackgroundVariant,
|
||||
Controls,
|
||||
type Edge as RFEdge,
|
||||
Handle,
|
||||
MarkerType,
|
||||
MiniMap,
|
||||
type Node as RFNode,
|
||||
type NodeProps,
|
||||
Panel,
|
||||
Position,
|
||||
ReactFlow,
|
||||
ReactFlowProvider,
|
||||
useEdgesState,
|
||||
useNodesState,
|
||||
} from "@xyflow/react";
|
||||
import "@xyflow/react/dist/style.css";
|
||||
import {
|
||||
Bug,
|
||||
Compass,
|
||||
Flag,
|
||||
FlaskConical,
|
||||
Lightbulb,
|
||||
type LucideIcon,
|
||||
Target,
|
||||
} from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { StatusBadge } from "@/components/status-badge";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import { toneClasses, type Tone } from "@/lib/status";
|
||||
import { api } from "@/lib/api";
|
||||
import type { Edge, ExploreKind, TaskNode } from "@/lib/types";
|
||||
|
||||
const relLabel: Record<string, string> = {
|
||||
spawns: "派生",
|
||||
derived_from: "意图链",
|
||||
yields: "产出",
|
||||
proves: "证明",
|
||||
};
|
||||
|
||||
const relColor: Record<string, string> = {
|
||||
spawns: "#10b981",
|
||||
derived_from: "#6366f1",
|
||||
yields: "#f59e0b",
|
||||
proves: "#ef4444",
|
||||
};
|
||||
|
||||
type TypeMeta = {
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
iconBg: string; // Dify 风格实心图标块底色
|
||||
chip: string; // 抽屉里用的柔和底色
|
||||
hex: string; // minimap / 连线取色
|
||||
};
|
||||
|
||||
const typeMeta: Record<ExploreKind, TypeMeta> = {
|
||||
task: {
|
||||
label: "根任务",
|
||||
icon: Flag,
|
||||
iconBg: "bg-slate-500",
|
||||
chip: "bg-slate-500/15 text-slate-600 dark:text-slate-300",
|
||||
hex: "#64748b",
|
||||
},
|
||||
begin: {
|
||||
label: "起点",
|
||||
icon: Flag,
|
||||
iconBg: "bg-slate-500",
|
||||
chip: "bg-slate-500/15 text-slate-600 dark:text-slate-300",
|
||||
hex: "#64748b",
|
||||
},
|
||||
goal: {
|
||||
label: "目标",
|
||||
icon: Target,
|
||||
iconBg: "bg-emerald-500",
|
||||
chip: "bg-emerald-500/15 text-emerald-600 dark:text-emerald-400",
|
||||
hex: "#10b981",
|
||||
},
|
||||
hint: {
|
||||
label: "提示",
|
||||
icon: Lightbulb,
|
||||
iconBg: "bg-violet-500",
|
||||
chip: "bg-violet-500/15 text-violet-600 dark:text-violet-400",
|
||||
hex: "#8b5cf6",
|
||||
},
|
||||
intent: {
|
||||
label: "意图",
|
||||
icon: Compass,
|
||||
iconBg: "bg-blue-500",
|
||||
chip: "bg-blue-500/15 text-blue-600 dark:text-blue-400",
|
||||
hex: "#3b82f6",
|
||||
},
|
||||
fact: {
|
||||
label: "事实",
|
||||
icon: FlaskConical,
|
||||
iconBg: "bg-amber-500",
|
||||
chip: "bg-amber-500/15 text-amber-600 dark:text-amber-400",
|
||||
hex: "#f59e0b",
|
||||
},
|
||||
finding: {
|
||||
label: "漏洞",
|
||||
icon: Bug,
|
||||
iconBg: "bg-rose-500",
|
||||
chip: "bg-rose-500/15 text-rose-600 dark:text-rose-400",
|
||||
hex: "#f43f5e",
|
||||
},
|
||||
};
|
||||
|
||||
// The task root is now an origin fact (kind='fact', state='origin') rather than a
|
||||
// dedicated 'begin' node. Render/layout it with the "起点" visual (the `begin`
|
||||
// entry above) so the graph root still reads as the origin.
|
||||
function viewKind(n: TaskNode): ExploreKind {
|
||||
return n.type === "fact" && n.state === "origin" ? "begin" : n.type;
|
||||
}
|
||||
|
||||
function prioTone(p: number): Tone {
|
||||
return p >= 8 ? "red" : p >= 5 ? "amber" : "slate";
|
||||
}
|
||||
|
||||
// 各类型节点在卡片上展示的字段:payload 通常是一段 JSON 字符串,
|
||||
// 按类型取其中的 summary / text / description 作为显示内容。
|
||||
const summaryFields: Record<ExploreKind, string[]> = {
|
||||
task: ["summary", "description"],
|
||||
begin: ["summary", "description"],
|
||||
goal: ["text"],
|
||||
hint: [],
|
||||
intent: ["summary"],
|
||||
fact: ["summary"],
|
||||
finding: ["summary"],
|
||||
};
|
||||
|
||||
// 解析 payload 得到节点显示文案;解析失败或字段缺失时回退到原始 payload。
|
||||
function nodeSummary(n: TaskNode): string {
|
||||
const raw = n.payload ?? "";
|
||||
const fields = summaryFields[viewKind(n)] ?? [];
|
||||
if (fields.length && raw.trim()) {
|
||||
try {
|
||||
const obj: unknown = JSON.parse(raw);
|
||||
if (obj && typeof obj === "object") {
|
||||
for (const f of fields) {
|
||||
const v = (obj as Record<string, unknown>)[f];
|
||||
if (typeof v === "string" && v.trim()) return v;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* 非 JSON:按原始内容显示 */
|
||||
}
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
const COL_W = 360;
|
||||
const ROW_H = 140;
|
||||
|
||||
type ExploreNodeData = { node: TaskNode };
|
||||
type ExploreRFNode = RFNode<ExploreNodeData, "explore">;
|
||||
|
||||
function pushMap(m: Map<string, string[]>, k: string, v: string) {
|
||||
const a = m.get(k);
|
||||
if (a) a.push(v);
|
||||
else m.set(k, [v]);
|
||||
}
|
||||
|
||||
// 按连线的“递进深度”自动分层:顺着边从左往右排(意图→事实,事实/漏洞→再生意图…),
|
||||
// 而不是按节点类型固定分列——这样反向/回环的边才不会互相缠绕。
|
||||
// 1) DFS 断环:指向递归栈中节点的边视为回边,不参与分层(处理 意图↔事实 这类回环);
|
||||
// 2) 最长路径分层:谁在链路上更靠后,列就越靠右;
|
||||
// 3) 重心排序:每层按相邻层邻居的平均位置重排,让有连线的节点对齐、减少交叉;
|
||||
// 4) 每层整体垂直居中。
|
||||
function computeLayout(
|
||||
nodes: TaskNode[],
|
||||
edges: Edge[],
|
||||
): Map<string, { x: number; y: number }> {
|
||||
const order = nodes.map((n) => n.id);
|
||||
const idset = new Set(order);
|
||||
const valid = edges.filter((e) => idset.has(e.src) && idset.has(e.dst));
|
||||
const key = (s: string, d: string) => `${s}->${d}`;
|
||||
|
||||
const out = new Map<string, string[]>();
|
||||
for (const e of valid) pushMap(out, e.src, e.dst);
|
||||
|
||||
// 1) 迭代式 DFS 断环
|
||||
const seen = new Map<string, 0 | 1 | 2>();
|
||||
const back = new Set<string>();
|
||||
for (const root of order) {
|
||||
if ((seen.get(root) ?? 0) !== 0) continue;
|
||||
const stack: Array<{ u: string; i: number }> = [{ u: root, i: 0 }];
|
||||
seen.set(root, 1);
|
||||
while (stack.length) {
|
||||
const top = stack[stack.length - 1];
|
||||
const nbrs = out.get(top.u) ?? [];
|
||||
if (top.i >= nbrs.length) {
|
||||
seen.set(top.u, 2);
|
||||
stack.pop();
|
||||
continue;
|
||||
}
|
||||
const v = nbrs[top.i++];
|
||||
const s = seen.get(v) ?? 0;
|
||||
if (s === 1) back.add(key(top.u, v)); // 回边
|
||||
else if (s === 0) {
|
||||
seen.set(v, 1);
|
||||
stack.push({ u: v, i: 0 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2) 最长路径分层(忽略回边)
|
||||
const dagSucc = new Map<string, string[]>();
|
||||
const indeg = new Map<string, number>(order.map((id) => [id, 0]));
|
||||
for (const e of valid) {
|
||||
if (back.has(key(e.src, e.dst))) continue;
|
||||
pushMap(dagSucc, e.src, e.dst);
|
||||
indeg.set(e.dst, (indeg.get(e.dst) ?? 0) + 1);
|
||||
}
|
||||
const layer = new Map<string, number>(order.map((id) => [id, 0]));
|
||||
const queue = order.filter((id) => (indeg.get(id) ?? 0) === 0);
|
||||
for (let qi = 0; qi < queue.length; qi++) {
|
||||
const u = queue[qi];
|
||||
for (const v of dagSucc.get(u) ?? []) {
|
||||
layer.set(v, Math.max(layer.get(v) ?? 0, (layer.get(u) ?? 0) + 1));
|
||||
const d = (indeg.get(v) ?? 0) - 1;
|
||||
indeg.set(v, d);
|
||||
if (d === 0) queue.push(v);
|
||||
}
|
||||
}
|
||||
|
||||
// 3) 按层分列
|
||||
const maxLayer = Math.max(0, ...layer.values());
|
||||
const columns: string[][] = Array.from({ length: maxLayer + 1 }, () => []);
|
||||
for (const id of order) columns[layer.get(id) ?? 0].push(id);
|
||||
|
||||
// 重心排序:只统计相邻层之间的连线
|
||||
const colIndexOf = new Map<string, number>();
|
||||
columns.forEach((ids, c) => ids.forEach((id) => colIndexOf.set(id, c)));
|
||||
const neighbors = new Map<string, string[]>();
|
||||
for (const e of valid) {
|
||||
const cs = colIndexOf.get(e.src);
|
||||
const cd = colIndexOf.get(e.dst);
|
||||
if (cs === undefined || cd === undefined || Math.abs(cs - cd) !== 1) continue;
|
||||
pushMap(neighbors, e.src, e.dst);
|
||||
pushMap(neighbors, e.dst, e.src);
|
||||
}
|
||||
const sweep = (dir: 1 | -1) => {
|
||||
const start = dir === 1 ? 1 : columns.length - 2;
|
||||
const end = dir === 1 ? columns.length : -1;
|
||||
for (let c = start; c !== end; c += dir) {
|
||||
const ref = c - dir;
|
||||
const refIndex = new Map(columns[ref].map((id, i) => [id, i]));
|
||||
const scored = columns[c].map((id, i) => {
|
||||
const ns = (neighbors.get(id) ?? [])
|
||||
.map((nid) => refIndex.get(nid))
|
||||
.filter((v): v is number => v !== undefined);
|
||||
const bary = ns.length ? ns.reduce((a, b) => a + b, 0) / ns.length : i;
|
||||
return { id, bary, i };
|
||||
});
|
||||
scored.sort((a, b) => a.bary - b.bary || a.i - b.i);
|
||||
columns[c] = scored.map((s) => s.id);
|
||||
}
|
||||
};
|
||||
for (let iter = 0; iter < 4; iter++) {
|
||||
sweep(1);
|
||||
sweep(-1);
|
||||
}
|
||||
|
||||
// 4) 垂直居中
|
||||
const maxCount = Math.max(1, ...columns.map((c) => c.length));
|
||||
const midline = ((maxCount - 1) * ROW_H) / 2;
|
||||
const pos = new Map<string, { x: number; y: number }>();
|
||||
columns.forEach((ids, c) => {
|
||||
const startY = midline - ((ids.length - 1) * ROW_H) / 2;
|
||||
ids.forEach((id, i) => pos.set(id, { x: c * COL_W, y: startY + i * ROW_H }));
|
||||
});
|
||||
return pos;
|
||||
}
|
||||
|
||||
// Dify 工作流风格节点:白色圆角卡片 + 实心配色图标块 + 选中/运行蓝色描边。
|
||||
function ExploreNode({ data, selected }: NodeProps<ExploreRFNode>) {
|
||||
const n = data.node;
|
||||
const kind = viewKind(n);
|
||||
const meta = typeMeta[kind] ?? typeMeta.intent;
|
||||
const Icon = meta.icon;
|
||||
const showState = n.type === "goal" || n.type === "intent";
|
||||
const showPriority = (n.type === "goal" || n.type === "intent") && n.priority > 0;
|
||||
const isLive = n.type === "intent" && n.state === "running";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"w-[240px] cursor-pointer rounded-2xl border-2 transition-colors",
|
||||
selected ? "border-blue-500" : isLive ? "border-blue-400/70" : "border-transparent",
|
||||
)}
|
||||
>
|
||||
<div className="group relative rounded-[14px] border border-black/[0.06] bg-white shadow-sm transition-shadow hover:shadow-lg dark:border-white/10 dark:bg-neutral-900">
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
className="!size-2 !border-2 !border-neutral-300 !bg-white opacity-0 transition-opacity group-hover:opacity-100 dark:!border-neutral-600 dark:!bg-neutral-800"
|
||||
/>
|
||||
{/* 头部:实心图标块 + 类型标题 + 状态点 / 优先级 */}
|
||||
<div className="flex items-center gap-2 px-3 pt-3 pb-1.5">
|
||||
<span
|
||||
className={cn(
|
||||
"flex size-6 shrink-0 items-center justify-center rounded-lg shadow-sm",
|
||||
meta.iconBg,
|
||||
)}
|
||||
>
|
||||
<Icon className="size-3.5 text-white" />
|
||||
</span>
|
||||
<span className="grow truncate text-[13px] font-semibold text-neutral-700 dark:text-neutral-100">
|
||||
{meta.label}
|
||||
</span>
|
||||
{isLive && (
|
||||
<span className="relative flex size-2 shrink-0">
|
||||
<span className="absolute inline-flex size-full animate-ping rounded-full bg-blue-400 opacity-70" />
|
||||
<span className="relative inline-flex size-2 rounded-full bg-blue-500" />
|
||||
</span>
|
||||
)}
|
||||
{showPriority && (
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 rounded-md px-1.5 py-0.5 text-[10px] font-semibold tabular-nums",
|
||||
toneClasses[prioTone(n.priority)],
|
||||
)}
|
||||
>
|
||||
P{n.priority}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* 摘要 */}
|
||||
<div className="line-clamp-3 px-3 pb-2 text-[13px] leading-snug text-neutral-500 dark:text-neutral-400">
|
||||
{nodeSummary(n) || meta.label}
|
||||
</div>
|
||||
{/* 底部:状态徽章 + 来源/ID */}
|
||||
<div className="flex items-center justify-between gap-2 px-3 pb-2.5">
|
||||
{showState ? (
|
||||
<StatusBadge
|
||||
domain={n.type === "goal" ? "goal" : "intent"}
|
||||
value={n.state}
|
||||
dot
|
||||
className="px-1.5 py-0 text-[10px]"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-[10px] text-neutral-400 dark:text-neutral-500">
|
||||
{meta.label}
|
||||
</span>
|
||||
)}
|
||||
<span className="shrink-0 font-mono text-[10px] text-neutral-400 dark:text-neutral-500">
|
||||
#{n.id}
|
||||
</span>
|
||||
</div>
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
className="!size-2 !border-2 !border-neutral-300 !bg-white opacity-0 transition-opacity group-hover:opacity-100 dark:!border-neutral-600 dark:!bg-neutral-800"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const nodeTypes = { explore: ExploreNode };
|
||||
|
||||
function DetailRow({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-start gap-3 py-1.5 text-sm">
|
||||
<span className="text-muted-foreground w-14 shrink-0">{label}</span>
|
||||
<span className="text-foreground min-w-0 flex-1 break-words">{children}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 点击节点后弹出的抽屉:上半展示可读摘要,下半展示节点原始 JSON。
|
||||
function NodeDetailSheet({
|
||||
node,
|
||||
onOpenChange,
|
||||
}: {
|
||||
node: TaskNode | null;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
const meta = node ? (typeMeta[viewKind(node)] ?? typeMeta.intent) : null;
|
||||
const Icon = meta?.icon;
|
||||
const raw = node ? JSON.stringify(node, null, 2) : "";
|
||||
|
||||
const copy = () => {
|
||||
void navigator.clipboard?.writeText(raw).then(() => {
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 1500);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Sheet open={node !== null} onOpenChange={onOpenChange}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
className="flex w-full flex-col gap-0 p-0 data-[side=right]:sm:max-w-md"
|
||||
>
|
||||
{node && meta && Icon && (
|
||||
<>
|
||||
<SheetHeader className="border-b p-4">
|
||||
<div className="flex items-center gap-2.5 pr-8">
|
||||
<span
|
||||
className={cn(
|
||||
"flex size-8 shrink-0 items-center justify-center rounded-lg shadow-sm",
|
||||
meta.iconBg,
|
||||
)}
|
||||
>
|
||||
<Icon className="size-4 text-white" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<SheetTitle className="leading-tight">{meta.label}</SheetTitle>
|
||||
<span className="text-muted-foreground font-mono text-xs">{node.id}</span>
|
||||
</div>
|
||||
</div>
|
||||
</SheetHeader>
|
||||
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div className="flex w-full min-w-0 flex-col gap-4 p-4">
|
||||
<section>
|
||||
<h4 className="text-muted-foreground mb-1 text-xs font-medium">属性</h4>
|
||||
<DetailRow label="类型">{meta.label}</DetailRow>
|
||||
<DetailRow label="状态">
|
||||
{node.type === "goal" || node.type === "intent" ? (
|
||||
<StatusBadge
|
||||
domain={node.type === "goal" ? "goal" : "intent"}
|
||||
value={node.state}
|
||||
dot
|
||||
className="px-1.5 py-0 text-[10px]"
|
||||
/>
|
||||
) : (
|
||||
<span className="font-mono text-xs">{node.state}</span>
|
||||
)}
|
||||
</DetailRow>
|
||||
<DetailRow label="优先级">
|
||||
<span className="tabular-nums">{node.priority}</span>
|
||||
</DetailRow>
|
||||
<DetailRow label="来源">
|
||||
<span className="font-mono text-xs">{node.origin}</span>
|
||||
</DetailRow>
|
||||
<DetailRow label="时间">{new Date(node.ts).toLocaleString("zh-CN")}</DetailRow>
|
||||
</section>
|
||||
|
||||
<section className="border-t pt-3">
|
||||
<div className="mb-1.5 flex items-center justify-between">
|
||||
<h4 className="text-muted-foreground text-xs font-medium">原始数据</h4>
|
||||
<button
|
||||
type="button"
|
||||
onClick={copy}
|
||||
className="text-muted-foreground hover:text-foreground text-xs transition-colors"
|
||||
>
|
||||
{copied ? "已复制" : "复制"}
|
||||
</button>
|
||||
</div>
|
||||
<pre className="bg-muted/50 text-foreground max-w-full overflow-hidden rounded-md border p-3 font-mono text-xs leading-relaxed break-all whitespace-pre-wrap">
|
||||
{raw}
|
||||
</pre>
|
||||
</section>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
function GraphInner({ taskId }: { taskId: string }) {
|
||||
const [rfNodes, setRfNodes, onNodesChange] = useNodesState<ExploreRFNode>([]);
|
||||
const [rfEdges, setRfEdges, onEdgesChange] = useEdgesState<RFEdge>([]);
|
||||
const [isEmpty, setIsEmpty] = React.useState(true);
|
||||
const [selected, setSelected] = React.useState<TaskNode | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
const load = () => {
|
||||
api
|
||||
.explorationGraph(taskId)
|
||||
.then((g) => {
|
||||
if (cancelled) return;
|
||||
const nodes = g.nodes ?? [];
|
||||
const edges: Edge[] = g.edges ?? [];
|
||||
setIsEmpty(nodes.length === 0);
|
||||
// 抽屉打开时,让其中的数据随轮询保持最新。
|
||||
setSelected((cur) => (cur ? (nodes.find((n) => n.id === cur.id) ?? cur) : cur));
|
||||
|
||||
// 目标指向「执行中」意图的连线做流动动画,直观展示活跃探索路径。
|
||||
const liveTargets = new Set(
|
||||
nodes.filter((n) => n.type === "intent" && n.state === "running").map((n) => n.id),
|
||||
);
|
||||
|
||||
const layout = computeLayout(nodes, edges);
|
||||
setRfNodes((prev) => {
|
||||
const prevPos = new Map(prev.map((p) => [p.id, p.position]));
|
||||
return nodes.map((n) => ({
|
||||
id: n.id,
|
||||
type: "explore" as const,
|
||||
position: prevPos.get(n.id) ?? layout.get(n.id) ?? { x: 0, y: 0 },
|
||||
data: { node: n },
|
||||
sourcePosition: Position.Right,
|
||||
targetPosition: Position.Left,
|
||||
}));
|
||||
});
|
||||
setRfEdges(
|
||||
edges.map((e, i) => {
|
||||
const color = relColor[e.rel] ?? "#94a3b8";
|
||||
const animated = liveTargets.has(e.dst);
|
||||
return {
|
||||
id: `e${i}-${e.src}-${e.dst}`,
|
||||
source: e.src,
|
||||
target: e.dst,
|
||||
label: relLabel[e.rel] ?? e.rel,
|
||||
type: "default",
|
||||
animated,
|
||||
labelShowBg: false,
|
||||
labelStyle: { fontSize: 10, fontWeight: 600, fill: color },
|
||||
style: {
|
||||
stroke: color,
|
||||
strokeWidth: 2,
|
||||
strokeOpacity: animated ? 0.9 : 0.55,
|
||||
},
|
||||
markerEnd: { type: MarkerType.ArrowClosed, color, width: 14, height: 14 },
|
||||
} satisfies RFEdge;
|
||||
}),
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
/* keep last good data */
|
||||
});
|
||||
};
|
||||
load();
|
||||
const timer = setInterval(load, 3000);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, [taskId, setRfNodes, setRfEdges]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ReactFlow
|
||||
nodes={rfNodes}
|
||||
edges={rfEdges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onNodeClick={(_, node) => setSelected(node.data.node)}
|
||||
nodeTypes={nodeTypes}
|
||||
fitView
|
||||
fitViewOptions={{ padding: 0.2 }}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
minZoom={0.1}
|
||||
className="!bg-[#f0f2f7] dark:!bg-neutral-950"
|
||||
>
|
||||
<Background
|
||||
variant={BackgroundVariant.Dots}
|
||||
gap={16}
|
||||
size={1}
|
||||
className="text-neutral-400/50 dark:text-neutral-700/60"
|
||||
/>
|
||||
<Controls
|
||||
showInteractive={false}
|
||||
className="!rounded-lg !border !shadow-sm [&>button]:!border-border [&>button]:!bg-card [&>button:hover]:!bg-accent [&_svg]:!fill-foreground"
|
||||
/>
|
||||
<MiniMap
|
||||
pannable
|
||||
zoomable
|
||||
className="!bg-card !rounded-lg !border !shadow-sm"
|
||||
maskColor="rgb(148 163 184 / 0.18)"
|
||||
nodeColor={(node) => {
|
||||
const data = node.data as ExploreNodeData | undefined;
|
||||
const k = data?.node ? viewKind(data.node) : "intent";
|
||||
return typeMeta[k]?.hex ?? "#94a3b8";
|
||||
}}
|
||||
nodeStrokeWidth={0}
|
||||
nodeBorderRadius={4}
|
||||
/>
|
||||
<Panel position="top-left">
|
||||
<div className="bg-card/95 flex flex-col gap-2.5 rounded-lg border p-3 text-xs shadow-sm backdrop-blur">
|
||||
{isEmpty && <span className="text-muted-foreground">暂无探索数据</span>}
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1.5">
|
||||
{(["begin", "goal", "intent", "fact", "finding", "hint"] as ExploreKind[]).map((k) => {
|
||||
const m = typeMeta[k];
|
||||
const Icon = m.icon;
|
||||
return (
|
||||
<span key={k} className="text-foreground inline-flex items-center gap-1.5">
|
||||
<span className={cn("flex size-4 items-center justify-center rounded", m.iconBg)}>
|
||||
<Icon className="size-2.5 text-white" />
|
||||
</span>
|
||||
{m.label}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="border-border/60 text-muted-foreground flex flex-wrap gap-x-3 gap-y-1.5 border-t pt-2">
|
||||
{Object.entries(relLabel).map(([k, v]) => (
|
||||
<span key={k} className="inline-flex items-center gap-1.5">
|
||||
<span className="h-0.5 w-4 rounded-full" style={{ backgroundColor: relColor[k] }} />
|
||||
{v}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
</ReactFlow>
|
||||
<NodeDetailSheet node={selected} onOpenChange={(o) => !o && setSelected(null)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function GraphTab({ taskId }: { taskId: string }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<div className="h-[72vh] w-full overflow-hidden rounded-xl">
|
||||
<ReactFlowProvider>
|
||||
<GraphInner taskId={taskId} />
|
||||
</ReactFlowProvider>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { CheckIcon, RefreshCwIcon, ShieldAlertIcon, XIcon } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { api } from "@/lib/api";
|
||||
import type { InterceptApprovalRow } from "@/lib/types";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
|
||||
function fmtTime(s: string) {
|
||||
if (!s) return "—";
|
||||
return new Date(s).toLocaleString("zh-CN", {
|
||||
month: "2-digit", day: "2-digit",
|
||||
hour: "2-digit", minute: "2-digit", second: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
function AgentChip({ name }: { name: string }) {
|
||||
if (!name) return <span className="text-muted-foreground text-xs">—</span>;
|
||||
const color =
|
||||
name === "planner" ? "bg-amber-500" :
|
||||
name === "mainagent" ? "bg-primary" :
|
||||
"bg-sky-600";
|
||||
return (
|
||||
<span className={`inline-block rounded px-1.5 py-0.5 text-[10px] font-medium text-white ${color}`}>
|
||||
{name}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
if (status === "pending") return <Badge variant="outline" className="border-amber-400 text-amber-600">待审批</Badge>;
|
||||
if (status === "allowed") return <Badge variant="outline" className="border-emerald-500 text-emerald-600">已允许</Badge>;
|
||||
if (status === "denied") return <Badge variant="outline" className="border-red-500 text-red-600">已拒绝</Badge>;
|
||||
return <Badge variant="outline" className="text-muted-foreground">已超时</Badge>;
|
||||
}
|
||||
|
||||
function InputPreview({ input }: { input: Record<string, unknown> }) {
|
||||
const full = JSON.stringify(input, null, 2);
|
||||
const s = JSON.stringify(input);
|
||||
const short = s.length > 100 ? s.slice(0, 100) + "…" : s;
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<code className="block w-full cursor-default truncate font-mono text-[11px] text-muted-foreground">{short}</code>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="start" className="max-w-sm">
|
||||
<pre className="whitespace-pre-wrap break-all font-mono text-xs">{full}</pre>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
interface Props {
|
||||
taskId: string;
|
||||
}
|
||||
|
||||
export function InterceptTab({ taskId }: Props) {
|
||||
const [rows, setRows] = React.useState<InterceptApprovalRow[]>([]);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [deciding, setDeciding] = React.useState<number | null>(null);
|
||||
|
||||
const load = React.useCallback(async () => {
|
||||
try {
|
||||
setRows(await api.interceptTask(taskId));
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [taskId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
load();
|
||||
const t = setInterval(load, 5000);
|
||||
return () => clearInterval(t);
|
||||
}, [load]);
|
||||
|
||||
async function decide(id: number, decision: "allowed" | "denied") {
|
||||
if (deciding !== null) return;
|
||||
setDeciding(id);
|
||||
try {
|
||||
await api.interceptDecide(id, decision);
|
||||
toast.success(decision === "allowed" ? "已允许执行" : "已拒绝执行");
|
||||
setRows((prev) =>
|
||||
prev.map((r) => r.id === id ? { ...r, status: decision, decided_at: new Date().toISOString() } : r),
|
||||
);
|
||||
} catch (e) {
|
||||
toast.error((e as Error).message);
|
||||
} finally {
|
||||
setDeciding(null);
|
||||
}
|
||||
}
|
||||
|
||||
const pending = rows.filter((r) => r.status === "pending");
|
||||
|
||||
if (!loading && rows.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-3 py-20 text-center">
|
||||
<ShieldAlertIcon className="h-8 w-8 text-muted-foreground/30" />
|
||||
<p className="text-sm text-muted-foreground">此任务暂无拦截审批记录</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<ShieldAlertIcon className="h-4 w-4 text-muted-foreground" />
|
||||
拦截审批
|
||||
{pending.length > 0 && (
|
||||
<Badge className="rounded-full bg-amber-500 text-white text-[10px] px-1.5">
|
||||
{pending.length} 待审批
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={load} disabled={loading}>
|
||||
<RefreshCwIcon className="mr-1 h-3.5 w-3.5" />
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* pending block */}
|
||||
{pending.length > 0 && (
|
||||
<div className="rounded-lg border border-amber-400/40 bg-amber-50/30 dark:bg-amber-950/10">
|
||||
<div className="flex items-center gap-2 border-b border-amber-400/30 px-4 py-2.5 text-xs font-medium text-amber-700 dark:text-amber-400">
|
||||
<ShieldAlertIcon className="h-3.5 w-3.5" />
|
||||
待处理({pending.length})— 超时 5 分钟后自动拒绝
|
||||
</div>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-12">#</TableHead>
|
||||
<TableHead className="w-28">Agent</TableHead>
|
||||
<TableHead>工具</TableHead>
|
||||
<TableHead>规则</TableHead>
|
||||
<TableHead className="w-48">参数</TableHead>
|
||||
<TableHead>时间</TableHead>
|
||||
<TableHead className="w-32 text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{pending.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell className="text-muted-foreground text-xs">{row.id}</TableCell>
|
||||
<TableCell><AgentChip name={row.agent_name} /></TableCell>
|
||||
<TableCell>
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 text-xs font-mono">{row.tool_name}</code>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">{row.rule_name || "—"}</TableCell>
|
||||
<TableCell className="w-48 max-w-[12rem]"><InputPreview input={row.tool_input} /></TableCell>
|
||||
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">{fmtTime(row.created_at)}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button size="sm" className="h-7 px-2 text-xs" disabled={deciding === row.id} onClick={() => decide(row.id, "allowed")}>
|
||||
<CheckIcon className="mr-1 h-3 w-3" />允许
|
||||
</Button>
|
||||
<Button size="sm" variant="destructive" className="h-7 px-2 text-xs" disabled={deciding === row.id} onClick={() => decide(row.id, "denied")}>
|
||||
<XIcon className="mr-1 h-3 w-3" />拒绝
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* history table */}
|
||||
<div className="rounded-lg border">
|
||||
<div className="border-b px-4 py-2.5 text-xs font-medium text-muted-foreground">
|
||||
全部记录({rows.length})
|
||||
</div>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-12">#</TableHead>
|
||||
<TableHead className="w-28">Agent</TableHead>
|
||||
<TableHead>工具</TableHead>
|
||||
<TableHead>规则</TableHead>
|
||||
<TableHead className="w-48">参数</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>申请时间</TableHead>
|
||||
<TableHead>决定时间</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.map((row) => (
|
||||
<TableRow key={row.id} className={row.status === "pending" ? "opacity-40" : ""}>
|
||||
<TableCell className="text-muted-foreground text-xs">{row.id}</TableCell>
|
||||
<TableCell><AgentChip name={row.agent_name} /></TableCell>
|
||||
<TableCell>
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 text-xs font-mono">{row.tool_name}</code>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">{row.rule_name || "—"}</TableCell>
|
||||
<TableCell className="w-48 max-w-[12rem]"><InputPreview input={row.tool_input} /></TableCell>
|
||||
<TableCell><StatusBadge status={row.status} /></TableCell>
|
||||
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">{fmtTime(row.created_at)}</TableCell>
|
||||
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
|
||||
{row.decided_at ? fmtTime(row.decided_at) : "—"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import {
|
||||
ActivityIcon,
|
||||
AlertTriangleIcon,
|
||||
BugIcon,
|
||||
ClockIcon,
|
||||
ShieldCheckIcon,
|
||||
TargetIcon,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { StatusBadge } from "@/components/status-badge";
|
||||
import { api } from "@/lib/api";
|
||||
import type { Task, Stats, TaskNode, Finding } from "@/lib/types";
|
||||
|
||||
function StatCard({
|
||||
label,
|
||||
value,
|
||||
sub,
|
||||
icon: Icon,
|
||||
}: {
|
||||
label: string;
|
||||
value: React.ReactNode;
|
||||
sub?: string;
|
||||
icon: React.ElementType;
|
||||
}) {
|
||||
return (
|
||||
<Card className="gap-1.5">
|
||||
<CardHeader className="pb-0">
|
||||
<CardDescription className="flex items-center gap-1.5">
|
||||
<Icon className="size-3.5" /> {label}
|
||||
</CardDescription>
|
||||
<CardTitle className="text-2xl tabular-nums">{value}</CardTitle>
|
||||
</CardHeader>
|
||||
{sub && (
|
||||
<CardContent className="text-xs text-muted-foreground">{sub}</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function OverviewTab({ taskId }: { taskId: string }) {
|
||||
const [task, setTask] = React.useState<Task | null>(null);
|
||||
const [stats, setStats] = React.useState<Stats | null>(null);
|
||||
const [intents, setIntents] = React.useState<TaskNode[]>([]);
|
||||
const [findings, setFindings] = React.useState<Finding[]>([]);
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
const [tasksResp, statsResp, intentsResp, findingsResp] =
|
||||
await Promise.all([
|
||||
api.tasks(),
|
||||
api.stats(taskId),
|
||||
api.intents(taskId),
|
||||
api.findings(taskId),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
setTask(tasksResp.tasks.find((t) => t.id === taskId) ?? null);
|
||||
setStats(statsResp);
|
||||
setIntents(intentsResp);
|
||||
setFindings(findingsResp);
|
||||
} catch {
|
||||
// transient errors are ignored; the next poll will retry
|
||||
}
|
||||
};
|
||||
|
||||
load();
|
||||
const timer = setInterval(load, 3000);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, [taskId]);
|
||||
|
||||
const running = intents.filter((i) => i.state === "running");
|
||||
const open = intents.filter((i) => i.state === "open");
|
||||
const blocked = intents.filter((i) => i.state === "blocked");
|
||||
const taskFindings = findings.filter((f) => f.task_id === taskId);
|
||||
const goalsPct = task?.goals_total
|
||||
? Math.round(((task.goals_met ?? 0) / task.goals_total) * 100)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Heartbeat */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<ActivityIcon className="size-4 text-blue-500" /> 心跳
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">引擎态</div>
|
||||
<StatusBadge
|
||||
domain="engine"
|
||||
value={stats?.engine_mode ?? task?.engine_mode ?? "idle"}
|
||||
dot
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">运行中 Worker</div>
|
||||
<div className="mt-1 text-lg font-semibold tabular-nums">
|
||||
{running.length}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">最近活动</div>
|
||||
<div className="mt-1 inline-flex items-center gap-1 text-sm">
|
||||
<ClockIcon className="size-3.5" />
|
||||
{task?.last_activity
|
||||
? new Date(task.last_activity).toLocaleTimeString("zh-CN")
|
||||
: "—"}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
目标 {task?.goals_met ?? 0}/{task?.goals_total ?? 0}
|
||||
</div>
|
||||
<Progress value={goalsPct} className="mt-2" />
|
||||
</div>
|
||||
{task?.completed_unix && task.completed_unix > 0 ? (
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">完成时间</div>
|
||||
<div className="mt-1 inline-flex items-center gap-1 text-sm">
|
||||
<ClockIcon className="size-3.5" />
|
||||
{new Date(task.completed_unix * 1000).toLocaleString("zh-CN")}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Work set */}
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-sm">
|
||||
<TargetIcon className="size-4" /> 进行中意图
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-2">
|
||||
{running.slice(0, 6).map((i) => (
|
||||
<div key={i.id} className="flex items-center gap-2 text-sm">
|
||||
<StatusBadge domain="intent" value={i.state} />
|
||||
<span className="min-w-0 flex-1 truncate">{i.payload}</span>
|
||||
</div>
|
||||
))}
|
||||
{running.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">暂无进行中意图</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-sm">
|
||||
<AlertTriangleIcon className="size-4 text-amber-500" /> 需要关注
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div>
|
||||
<div className="text-2xl font-semibold tabular-nums text-red-600">
|
||||
{taskFindings.length}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">确认漏洞</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-semibold tabular-nums text-blue-600">
|
||||
{running.length}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">执行中</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-semibold tabular-nums">
|
||||
{open.length}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">frontier 待领</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-semibold tabular-nums text-red-600">
|
||||
{blocked.length}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">被拦意图</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-sm">
|
||||
<BugIcon className="size-4 text-red-500" /> 最近发现
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-2">
|
||||
{taskFindings.slice(0, 6).map((f) => (
|
||||
<div key={f.id} className="flex items-center gap-2 text-sm">
|
||||
<StatusBadge domain="severity" value={f.severity} dot />
|
||||
<span className="min-w-0 flex-1 truncate">{f.summary}</span>
|
||||
</div>
|
||||
))}
|
||||
{taskFindings.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">暂无发现</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Stat cards */}
|
||||
<div className="grid grid-cols-2 gap-4 lg:grid-cols-3">
|
||||
<StatCard
|
||||
label="待领意图"
|
||||
value={open.length}
|
||||
icon={ShieldCheckIcon}
|
||||
sub="frontier 开放"
|
||||
/>
|
||||
<StatCard
|
||||
label="确认发现"
|
||||
value={taskFindings.length}
|
||||
icon={BugIcon}
|
||||
sub="本任务"
|
||||
/>
|
||||
<StatCard
|
||||
label="意图总数"
|
||||
value={intents.length}
|
||||
icon={AlertTriangleIcon}
|
||||
sub="本任务全部意图"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { FileTextIcon, CopyIcon, CheckIcon } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { api } from "@/lib/api";
|
||||
|
||||
export function ReportTab({ taskId }: { taskId: string }) {
|
||||
const [report, setReport] = React.useState<string>("");
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
let active = true;
|
||||
setLoading(true);
|
||||
api
|
||||
.report(taskId)
|
||||
.then((text) => {
|
||||
if (active) setReport(text);
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) setReport("");
|
||||
})
|
||||
.finally(() => {
|
||||
if (active) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [taskId]);
|
||||
|
||||
function copy() {
|
||||
if (!report) return;
|
||||
navigator.clipboard?.writeText(report);
|
||||
setCopied(true);
|
||||
toast.success("已复制 Markdown");
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex-row items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-2 text-sm">
|
||||
<FileTextIcon className="size-4" /> 渗透测试报告(Markdown)
|
||||
</CardTitle>
|
||||
<div className="flex gap-2">
|
||||
{report && (
|
||||
<Button size="sm" variant="outline" onClick={copy}>
|
||||
{copied ? <CheckIcon /> : <CopyIcon />} 复制
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="flex flex-col items-center justify-center gap-2 rounded-md border border-dashed py-16 text-sm text-muted-foreground">
|
||||
<FileTextIcon className="size-8 opacity-40" />
|
||||
加载中…
|
||||
</div>
|
||||
) : report ? (
|
||||
<pre className="max-h-[60vh] overflow-auto rounded-md border bg-muted/40 p-4 font-mono text-xs whitespace-pre-wrap">
|
||||
{report}
|
||||
</pre>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center gap-2 rounded-md border border-dashed py-16 text-sm text-muted-foreground">
|
||||
<FileTextIcon className="size-8 opacity-40" />
|
||||
暂无报告
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,709 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import {
|
||||
RadioIcon,
|
||||
BrainIcon,
|
||||
UserIcon,
|
||||
Loader2Icon,
|
||||
ZapOffIcon,
|
||||
SendIcon,
|
||||
SquareIcon,
|
||||
ClockIcon,
|
||||
CircleCheckIcon,
|
||||
CircleXIcon,
|
||||
CircleSlashIcon,
|
||||
ShieldAlertIcon,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Transcript } from "@/components/transcript";
|
||||
import { TodoPopover } from "@/components/todo-popover";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { api, sseUrl } from "@/lib/api";
|
||||
import { MOCK } from "@/lib/mock/enabled";
|
||||
import type {
|
||||
Activity,
|
||||
InterceptApprovalRow,
|
||||
Session,
|
||||
SessionStatus,
|
||||
TaskNode,
|
||||
TokenTotal,
|
||||
} from "@/lib/types";
|
||||
|
||||
// statusIcon maps a session status to its icon. Worker terminal states are
|
||||
// distinct & color-coded: 完成(绿勾圈) / 取消停止(琥珀斜杠圈) / 出错(红叉圈) /
|
||||
// 步数耗尽(紫). running=蓝色转圈, pending(待领取)=灰时钟.
|
||||
function statusIcon(status: SessionStatus) {
|
||||
switch (status) {
|
||||
case "running": // 执行中
|
||||
return <Loader2Icon className="size-3.5 animate-spin text-blue-500" />;
|
||||
case "pending": // 待领取(open intent)
|
||||
return <ClockIcon className="size-3.5 text-muted-foreground" />;
|
||||
case "done": // 完成
|
||||
return <CircleCheckIcon className="size-3.5 text-emerald-500" />;
|
||||
case "stopped": // 取消/停止(被 planner 终止)
|
||||
return <CircleSlashIcon className="size-3.5 text-amber-500" />;
|
||||
case "blocked": // 出错
|
||||
return <CircleXIcon className="size-3.5 text-red-500" />;
|
||||
case "exhausted": // 步数耗尽(撞 max_turns)
|
||||
return <ZapOffIcon className="size-3.5 text-violet-500" />;
|
||||
}
|
||||
}
|
||||
|
||||
// fmtTokens renders a compact token count (1234 → 1.2k, 2_000_000 → 2M).
|
||||
function fmtTokens(n: number): string {
|
||||
if (n >= 1_000_000) return (n / 1_000_000).toFixed(n >= 10_000_000 ? 0 : 1) + "M";
|
||||
if (n >= 1000) return (n / 1000).toFixed(n >= 10000 ? 0 : 1) + "k";
|
||||
return String(n);
|
||||
}
|
||||
|
||||
// fmtDuration renders an elapsed milliseconds span compactly (90s → 1m30s).
|
||||
function fmtDuration(ms: number): string {
|
||||
const s = Math.floor(ms / 1000);
|
||||
if (s < 60) return `${s}s`;
|
||||
const m = Math.floor(s / 60);
|
||||
if (m < 60) return `${m}m${String(s % 60).padStart(2, "0")}s`;
|
||||
const h = Math.floor(m / 60);
|
||||
return `${h}h${String(m % 60).padStart(2, "0")}m`;
|
||||
}
|
||||
|
||||
const roleMeta = {
|
||||
mainagent: { label: "主 Agent", icon: UserIcon },
|
||||
planner: { label: "规划 Planner", icon: BrainIcon },
|
||||
worker: { label: "Workers", icon: RadioIcon },
|
||||
} as const;
|
||||
|
||||
// The main-agent session is the interactive entry point of this tab and has no
|
||||
// dedicated backend "sessions" endpoint — it is a fixed UI affordance whose
|
||||
// transcript is the full live activity stream for the task.
|
||||
const MAIN_ID = "s-main";
|
||||
const MAIN_SESSION: Session = {
|
||||
id: MAIN_ID,
|
||||
role: "mainagent",
|
||||
title: "主 Agent · 编排会话",
|
||||
status: "running",
|
||||
live: true,
|
||||
last_activity: "",
|
||||
};
|
||||
|
||||
// The planner session is, like the main-agent session, a fixed UI affordance with
|
||||
// no dedicated backend "sessions" endpoint — its transcript is every activity step
|
||||
// the planner emits (worker === "planner", which carries no intent_id since the
|
||||
// planner is what generates intents).
|
||||
const PLANNER_ID = "s-planner";
|
||||
const PLANNER_SESSION: Session = {
|
||||
id: PLANNER_ID,
|
||||
role: "planner",
|
||||
title: "规划 Planner · 态势研判",
|
||||
status: "running",
|
||||
live: true,
|
||||
last_activity: "",
|
||||
};
|
||||
|
||||
// Map an exploration intent (TaskNode) state → a session status the UI renders.
|
||||
function intentStatus(state: string): SessionStatus {
|
||||
switch (state) {
|
||||
case "done":
|
||||
return "done";
|
||||
case "blocked":
|
||||
return "blocked";
|
||||
case "exhausted":
|
||||
return "exhausted";
|
||||
case "stopped":
|
||||
return "stopped";
|
||||
case "open": // 待领取,区别于执行中
|
||||
return "pending";
|
||||
default: // running
|
||||
return "running";
|
||||
}
|
||||
}
|
||||
|
||||
// Derive worker sessions from running/open intents — there is no backend
|
||||
// sessions endpoint, so intents (≈ worker units) are the closest real source.
|
||||
function intentToSession(n: TaskNode): Session {
|
||||
const label = (n.payload ?? "").trim();
|
||||
const state = intentStatus(n.state);
|
||||
return {
|
||||
id: n.id,
|
||||
role: "worker",
|
||||
title: label || `Intent ${n.id}`,
|
||||
status: state,
|
||||
live: state === "running",
|
||||
last_activity: n.ts,
|
||||
intent_id: n.id,
|
||||
};
|
||||
}
|
||||
|
||||
function SessionItem({
|
||||
s,
|
||||
active,
|
||||
displayTitle,
|
||||
hasPending,
|
||||
onClick,
|
||||
}: {
|
||||
s: Session;
|
||||
active: boolean;
|
||||
displayTitle: string;
|
||||
hasPending?: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const icon =
|
||||
s.role === "worker" ? (
|
||||
statusIcon(s.status)
|
||||
) : s.live ? (
|
||||
<Loader2Icon className="size-3.5 animate-spin text-blue-500" />
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-1 rounded-md px-2 py-1.5 text-left text-sm transition-colors",
|
||||
active ? "bg-accent text-accent-foreground" : "hover:bg-accent/50",
|
||||
)}
|
||||
>
|
||||
{icon ?? <span className="size-3.5 shrink-0" />}
|
||||
{s.intent_id && (
|
||||
<span className="shrink-0 rounded bg-muted px-1 py-0.5 font-mono text-[10px] tabular-nums text-muted-foreground">
|
||||
#{s.intent_id}
|
||||
</span>
|
||||
)}
|
||||
<span className="min-w-0 flex-1 truncate">{displayTitle}</span>
|
||||
{hasPending && (
|
||||
<ShieldAlertIcon className="size-3.5 shrink-0 text-amber-500" />
|
||||
)}
|
||||
{s.live && (
|
||||
<span className="inline-flex items-center gap-1 rounded bg-blue-500/15 px-1.5 py-0.5 text-[10px] font-medium text-blue-600 dark:text-blue-400">
|
||||
<span className="size-1 animate-pulse rounded-full bg-blue-500" />
|
||||
实时
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function SessionsTab({ taskId }: { taskId: string }) {
|
||||
const [activeId, setActiveId] = React.useState(MAIN_ID);
|
||||
// Live activity stream — the primary data for this tab.
|
||||
const [allActivity, setAllActivity] = React.useState<Activity[]>([]);
|
||||
// Worker sessions derived from exploration intents.
|
||||
const [intents, setIntents] = React.useState<TaskNode[]>([]);
|
||||
// Local-only chat messages overlaid onto the main-agent transcript.
|
||||
const [chatExtra, setChatExtra] = React.useState<Activity[]>([]);
|
||||
const [input, setInput] = React.useState("");
|
||||
const [sending, setSending] = React.useState(false);
|
||||
const [stopping, setStopping] = React.useState(false);
|
||||
const [loaded, setLoaded] = React.useState(false);
|
||||
// Whole-task token total (all agents), polled from the backend aggregate.
|
||||
const [taskTokens, setTaskTokens] = React.useState<TokenTotal | null>(null);
|
||||
// Pending intercept requests for this task — used to show warning icons on sessions.
|
||||
const [pendingIntercepts, setPendingIntercepts] = React.useState<InterceptApprovalRow[]>([]);
|
||||
|
||||
React.useEffect(() => {
|
||||
let alive = true;
|
||||
const load = () =>
|
||||
api
|
||||
.tokenStats(taskId)
|
||||
.then((r) => {
|
||||
if (alive) setTaskTokens(r.total);
|
||||
})
|
||||
.catch(() => {});
|
||||
load();
|
||||
const t = setInterval(load, 5000);
|
||||
return () => {
|
||||
alive = false;
|
||||
clearInterval(t);
|
||||
};
|
||||
}, [taskId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
let alive = true;
|
||||
const load = () =>
|
||||
api.interceptTask(taskId)
|
||||
.then((rows) => { if (alive) setPendingIntercepts(rows.filter((r) => r.status === "pending")); })
|
||||
.catch(() => {});
|
||||
load();
|
||||
const t = setInterval(load, 5000);
|
||||
return () => { alive = false; clearInterval(t); };
|
||||
}, [taskId]);
|
||||
|
||||
// Re-render on a timer so "streaming" liveness recomputes as activity goes stale.
|
||||
const [, setTick] = React.useState(0);
|
||||
React.useEffect(() => {
|
||||
const id = setInterval(() => setTick((t) => t + 1), 1500);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
// A session is "streaming" only if its agent emitted activity in the last few
|
||||
// seconds (i.e. SSE actually in flight) — not a hardcoded always-on flag.
|
||||
const STREAM_WINDOW_MS = 5000;
|
||||
const streamingFor = React.useCallback(
|
||||
(w: string) => {
|
||||
let latest = 0;
|
||||
for (const a of allActivity) {
|
||||
if (a.worker === w) {
|
||||
const t = Date.parse(a.ts);
|
||||
if (t > latest) latest = t;
|
||||
}
|
||||
}
|
||||
return latest > 0 && Date.now() - latest < STREAM_WINDOW_MS;
|
||||
},
|
||||
[allActivity],
|
||||
);
|
||||
const mainLive = streamingFor("mainagent");
|
||||
const plannerLive = streamingFor("planner");
|
||||
|
||||
// Live activity stream via SSE: the endpoint replays history after since=0 then
|
||||
// tails each new step. EventSource auto-reconnects; on reconnect history replays
|
||||
// and the seq dedup below keeps it gap-free without duplicates.
|
||||
React.useEffect(() => {
|
||||
setAllActivity([]);
|
||||
setLoaded(false);
|
||||
// Mock demo:无 SSE 后端,一次性拉取活动快照(执行过程)。
|
||||
if (MOCK) {
|
||||
api
|
||||
.activity(taskId)
|
||||
.then((r) => setAllActivity(r.items))
|
||||
.catch(() => {})
|
||||
.finally(() => setLoaded(true));
|
||||
return;
|
||||
}
|
||||
const es = new EventSource(
|
||||
sseUrl(`/api/exploration/activity/stream?task=${encodeURIComponent(taskId)}&since=0`),
|
||||
);
|
||||
es.onopen = () => setLoaded(true);
|
||||
es.onmessage = (e) => {
|
||||
try {
|
||||
const a = JSON.parse(e.data) as Activity;
|
||||
setAllActivity((prev) =>
|
||||
prev.some((x) => x.seq === a.seq)
|
||||
? prev
|
||||
: [...prev, a].sort((p, q) => p.seq - q.seq),
|
||||
);
|
||||
} catch {
|
||||
/* ignore malformed frame */
|
||||
}
|
||||
};
|
||||
return () => es.close();
|
||||
}, [taskId]);
|
||||
|
||||
// Worker session list derives from exploration intents — poll lightly (changes
|
||||
// far less often than the activity stream).
|
||||
React.useEffect(() => {
|
||||
let active = true;
|
||||
const load = () =>
|
||||
api.intents(taskId).then((nodes) => {
|
||||
if (active) setIntents(nodes);
|
||||
}).catch(() => {});
|
||||
load();
|
||||
const t = setInterval(load, 5000);
|
||||
return () => {
|
||||
active = false;
|
||||
clearInterval(t);
|
||||
};
|
||||
}, [taskId]);
|
||||
|
||||
const workerSessions = React.useMemo(
|
||||
() => intents.map(intentToSession),
|
||||
[intents],
|
||||
);
|
||||
|
||||
// For each worker session (intent), derive the display title from the latest
|
||||
// activity summary (so the list shows what's actually happening, not the raw
|
||||
// intent payload). Fall back to payload when no activity exists yet.
|
||||
// Also store the full TaskNode for the hover-JSON tooltip.
|
||||
const sessionMeta = React.useMemo(() => {
|
||||
const map = new Map<string, { title: string; json: unknown }>();
|
||||
for (const node of intents) {
|
||||
let title = `Intent ${node.id}`;
|
||||
let parsedPayload: unknown = node.payload;
|
||||
if (node.payload) {
|
||||
try {
|
||||
const p = JSON.parse(node.payload);
|
||||
parsedPayload = p;
|
||||
if (p?.summary) title = String(p.summary);
|
||||
} catch {
|
||||
title = node.payload.trim() || title;
|
||||
}
|
||||
}
|
||||
// Hover JSON: full node with payload replaced by parsed object for readability
|
||||
const json = { ...node, payload: parsedPayload };
|
||||
map.set(node.id, { title, json });
|
||||
}
|
||||
return map;
|
||||
}, [intents]);
|
||||
|
||||
// Returns true if any pending intercept belongs to this session.
|
||||
// Worker agent_name format: "work#N · #intentID". Main/planner match by role key.
|
||||
const hasPendingForSession = React.useCallback(
|
||||
(s: Session): boolean => {
|
||||
if (!pendingIntercepts.length) return false;
|
||||
if (s.role === "mainagent") return pendingIntercepts.some((r) => r.agent_name === "mainagent");
|
||||
if (s.role === "planner") return pendingIntercepts.some((r) => r.agent_name === "planner");
|
||||
// Worker: extract intent ID from "work#N · #<intentID>"
|
||||
return pendingIntercepts.some((r) => {
|
||||
const m = r.agent_name.match(/·\s*#(\d+)$/);
|
||||
return m ? m[1] === s.intent_id : false;
|
||||
});
|
||||
},
|
||||
[pendingIntercepts],
|
||||
);
|
||||
|
||||
const sessions = React.useMemo(
|
||||
() => [
|
||||
{ ...MAIN_SESSION, live: mainLive },
|
||||
{ ...PLANNER_SESSION, live: plannerLive },
|
||||
...workerSessions,
|
||||
],
|
||||
[workerSessions, mainLive, plannerLive],
|
||||
);
|
||||
|
||||
const grouped = {
|
||||
mainagent: sessions.filter((s) => s.role === "mainagent"),
|
||||
planner: sessions.filter((s) => s.role === "planner"),
|
||||
worker: sessions.filter((s) => s.role === "worker"),
|
||||
};
|
||||
|
||||
const active = sessions.find((s) => s.id === activeId) ?? MAIN_SESSION;
|
||||
const isMain = active.role === "mainagent";
|
||||
const isPlanner = active.role === "planner";
|
||||
|
||||
// Main agent is the human↔orchestrator CONSOLE: it shows only the conversation
|
||||
// (the user's messages + the main agent's own replies/steps), used to steer the
|
||||
// run — NOT the planner/worker activity firehose (those live in their own
|
||||
// sessions). The planner session shows only planner steps; a worker session
|
||||
// shows only the activity for its intent.
|
||||
const activity = React.useMemo(() => {
|
||||
if (isMain) {
|
||||
// The conversation is persisted server-side in the activity stream
|
||||
// (worker="mainagent"), so it survives reloads. chatExtra is only an
|
||||
// optimistic local echo — drop any entry the persisted (SSE) copy already
|
||||
// covers, so messages don't show twice once the backend round-trips.
|
||||
const mine = allActivity.filter((a) => a.worker === "mainagent");
|
||||
const seen = new Set(mine.map((a) => `${a.kind} | ||||