Compare commits

..
5 Commits
Author SHA1 Message Date
baorong 95110511f9 feat: HTTP层增强(token刷新/重试) + ErrorBoundary全局异常 + 通知/日志/个人中心/管理员/菜单页面 + ExportButton/FileUpload通用组件 2026-04-26 14:03:50 +08:00
zuiidea 2d46d7bf27 chore(create): rename npm package to init-antd-admin
Replace antd-admin-cli (name taken on npm). Bin init-antd-admin;
release tags init-antd-admin-v*; workflow publish-init-antd-admin.yml.

Made-with: Cursor
2026-04-15 14:11:49 +08:00
zuiidea 0ecfefcd97 chore(create): rename npm package to antd-admin-cli
Unscoped name avoids npm similarity block with create-antd-admin.
Bin command antd-admin-cli; release tags antd-admin-cli-v*; workflow
publish-antd-admin-cli.yml.

Made-with: Cursor
2026-04-15 12:05:19 +08:00
zuiidea 57cd84b5ce chore(create): rename npm package to create-antdadmin
Use bin/create-antdadmin, npm create antdadmin, and tag prefix
create-antdadmin-v*. Workflow file publish-create-antdadmin.yml.
Env vars CREATE_ANTD_ADMIN_* unchanged.

Made-with: Cursor
2026-04-15 11:58:45 +08:00
zuiidea ca88d6567d fix(create): allow local npm publish without provenance
Remove publishConfig.provenance; CI passes --provenance on npm publish so
GitHub Actions still gets attestations with id-token: write.

Made-with: Cursor
2026-04-15 11:42:49 +08:00
67 changed files with 12204 additions and 6726 deletions
@@ -1,4 +1,4 @@
# Publish npm package `create-antd-admin` from packages/create
# Publish npm package `init-antd-admin` from packages/create
#
# Setup (one-time):
# 1. npmjs.com → Access Tokens → Granular or Automation token with publish rights
@@ -6,24 +6,24 @@
# Name: NPM_TOKEN Value: <token>
#
# Release:
# A) Manual: Actions → "Publish create-antd-admin" → Run workflow
# B) Tag: git tag create-antd-admin-v1.0.3 && git push origin create-antd-admin-v1.0.3
# A) Manual: Actions → "Publish init-antd-admin" → Run workflow
# B) Tag: git tag init-antd-admin-v1.0.3 && git push origin init-antd-admin-v1.0.3
# (tag suffix must match packages/create/package.json "version")
name: Publish create-antd-admin
name: Publish init-antd-admin
on:
workflow_dispatch:
push:
tags:
- "create-antd-admin-v*"
- "init-antd-admin-v*"
permissions:
contents: read
id-token: write
concurrency:
group: publish-create-antd-admin
group: publish-init-antd-admin
cancel-in-progress: true
jobs:
@@ -53,17 +53,17 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Test create-antd-admin
- name: Test init-antd-admin
run: pnpm test
working-directory: packages/create
- name: Build create-antd-admin
- name: Build init-antd-admin
run: pnpm run build
working-directory: packages/create
- name: Publish to npm
# provenance: see packages/create/package.json publishConfig (requires id-token: write)
run: npm publish --access public
# Provenance only in CI (OIDC). publishConfig must not force provenance or local npm publish fails.
run: npm publish --access public --provenance
working-directory: packages/create
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
+42 -1
View File
@@ -41,7 +41,7 @@ Balanced admin monorepo: MSW mocks, minimal RBAC, full CRUD, and Playwright E2E
## Templates
`pnpm dlx create-antd-admin@latest`
`npx init-antd-admin@latest` · `pnpm dlx init-antd-admin@latest`
| Directory | Use when |
@@ -76,6 +76,47 @@ cd apps/basic && pnpm run test:e2e:core
## Getting Started
Follow these steps to set up and run the project:
### Prerequisites
- **Node.js 20+**: Ensure you have Node.js installed.
- **pnpm**: Install pnpm globally using `npm install -g pnpm`.
### Installation
1. Clone the repository:
```bash
git clone https://github.com/zuiidea/antd-admin.git
cd antd-admin
```
2. Install dependencies:
```bash
pnpm install
```
### Running the Project
- **Basic App**:
```bash
pnpm --filter apps/basic dev
```
- **Lingui i18n App**:
```bash
pnpm --filter apps/with-lingui dev
```
### Testing
Run Playwright end-to-end tests:
```bash
pnpm test:e2e
```
## Getting Started
### Prerequisites
- [Node.js](https://nodejs.org) >= 20
+29 -3
View File
@@ -9,8 +9,34 @@ export async function loginAsAdmin(page: Page) {
await expect(page).toHaveURL(/dashboard/);
}
export async function gotoUsers(page: Page) {
await loginAsAdmin(page);
/** Navigate to the users page */
export async function gotoUsers(page: Page): Promise<void> {
await page.goto("/users");
await expect(page).toHaveURL(/users/);
await page.waitForSelector(".ant-table-thead");
}
/** Verify table headers are visible */
export async function verifyTableHeaders(page: Page): Promise<void> {
const thead = page.locator(".ant-table-thead");
await Promise.all([
thead.getByText(/Username|用户名/).isVisible(),
thead.getByText(/Email|邮箱/).isVisible(),
thead.getByText(/Roles|角色/).isVisible(),
]);
}
/** Search for a user by keyword */
export async function searchUser(page: Page, keyword: string): Promise<void> {
const searchInput = page.getByPlaceholder(/Search User|搜索用户/);
await searchInput.fill(keyword);
await searchInput.press("Enter");
await page.waitForURL((url) => url.searchParams.get("keyword") === keyword);
}
/** Verify role-based filtering */
export async function verifyRoleFilter(page: Page, role: string): Promise<void> {
await page.goto(`/users?role=${role}`);
await page.waitForSelector(".ant-table-thead");
const roleCell = page.getByRole("cell", { name: new RegExp(role, "i") });
await roleCell.first().isVisible();
}
+9 -19
View File
@@ -1,5 +1,10 @@
import { test, expect } from "@playwright/test";
import { gotoUsers, loginAsAdmin } from "./helpers";
import {
gotoUsers,
verifyTableHeaders,
searchUser,
verifyRoleFilter,
} from "./helpers";
test.describe("User Management", () => {
test.beforeEach(async ({ page }) => {
@@ -7,18 +12,11 @@ test.describe("User Management", () => {
});
test("should display user table", async ({ page }) => {
const thead = page.locator(".ant-table-thead");
await expect(thead.getByText(/Username|用户名/)).toBeVisible();
await expect(thead.getByText(/Email|邮箱/)).toBeVisible();
await expect(thead.getByText(/Roles|角色/)).toBeVisible();
await verifyTableHeaders(page);
});
test("should search users", async ({ page }) => {
const searchInput = page.getByPlaceholder(/Search User|搜索用户/);
await searchInput.fill("zhao.ming");
await searchInput.press("Enter");
await expect(page).toHaveURL(/keyword=zhao\.ming/);
/* Avoid strict-mode match on email cell containing substring "zhao.ming" */
await searchUser(page, "zhao.ming");
await expect(page.getByText("zhao.ming", { exact: true })).toBeVisible();
});
@@ -28,16 +26,8 @@ test.describe("User Management", () => {
});
});
/** Avoid a second full `page.goto` after `/users`: reload can land on login before auth rehydrates. */
test.describe("User Management — role in URL", () => {
test.beforeEach(async ({ page }) => {
await loginAsAdmin(page);
await page.goto("/users?role=admin");
await expect(page.getByPlaceholder(/Search User|搜索用户/)).toBeVisible({ timeout: 15_000 });
});
test("should filter users by role", async ({ page }) => {
await expect(page).toHaveURL(/role=admin/);
await expect(page.getByRole("cell", { name: "ops.admin@northstar.io" })).toBeVisible();
await verifyRoleFilter(page, "admin");
});
});
+7 -18
View File
@@ -16,10 +16,10 @@
"test:e2e:ui": "playwright test --ui"
},
"dependencies": {
"@tanstack/react-query": "^5.90.21",
"@tanstack/react-router": "^1.167.4",
"antd": "^6.3.3",
"lucide-react": "^1.7.0",
"@tanstack/react-query": "^5.100.3",
"@tanstack/react-router": "^1.168.24",
"antd": "^6.3.6",
"lucide-react": "^1.11.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"zod": "^4.3.6",
@@ -27,26 +27,15 @@
},
"devDependencies": {
"@playwright/test": "^1.58.2",
"@tanstack/router-plugin": "^1.166.13",
"@tanstack/router-plugin": "^1.167.26",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react-swc": "^4.3.0",
"msw": "^2.12.12",
"typescript": "~5.9.3",
"msw": "^2.13.6",
"typescript": "~6.0.3",
"vite": "npm:@voidzero-dev/vite-plus-core@latest",
"vite-plus": "latest"
},
"packageManager": "pnpm@10.32.1",
"pnpm": {
"overrides": {
"vite": "npm:@voidzero-dev/vite-plus-core@latest",
"vitest": "npm:@voidzero-dev/vite-plus-test@latest"
},
"onlyBuiltDependencies": [
"esbuild",
"msw"
]
},
"msw": {
"workerDirectory": [
"public"
+13
View File
@@ -0,0 +1,13 @@
import type { CreateAdminRequest, UpdateAdminRequest, User } from "./schemas";
export const ADMIN_ENDPOINTS = {
list: "/api/admins",
create: "/api/admins",
detail: (id: string) => `/api/admins/${id}`,
update: (id: string) => `/api/admins/${id}`,
delete: (id: string) => `/api/admins/${id}`,
} as const;
export type Admin = User;
export type { CreateAdminRequest, UpdateAdminRequest };
+10 -2
View File
@@ -1,11 +1,19 @@
import type { AuthTokens, LoginRequest, User, MenuItem, PermissionsList } from "./schemas";
import type {
AuthTokens,
LoginRequest,
RegisterRequest,
User,
MenuItem,
PermissionsList,
} from "./schemas";
export const AUTH_ENDPOINTS = {
login: "/api/auth/login",
register: "/api/auth/register",
refresh: "/api/auth/refresh",
logout: "/api/auth/logout",
user: "/api/auth/user",
permissions: "/api/auth/permissions",
} as const;
export type { AuthTokens, LoginRequest, User, MenuItem, PermissionsList };
export type { AuthTokens, LoginRequest, RegisterRequest, User, MenuItem, PermissionsList };
+3
View File
@@ -0,0 +1,3 @@
export const LOG_ENDPOINTS = {
list: "/api/logs",
} as const;
+8
View File
@@ -0,0 +1,8 @@
import type { MenuItem } from "./schemas";
export const MENU_ENDPOINTS = {
list: "/api/menus",
update: "/api/menus",
} as const;
export type { MenuItem };
+6
View File
@@ -0,0 +1,6 @@
export const NOTIFICATION_ENDPOINTS = {
list: "/api/notifications",
markRead: (id: string) => `/api/notifications/${id}/read`,
markAllRead: "/api/notifications/read-all",
delete: (id: string) => `/api/notifications/${id}`,
} as const;
+6
View File
@@ -0,0 +1,6 @@
export const PROFILE_ENDPOINTS = {
get: "/api/auth/user",
update: "/api/user/profile",
changePassword: "/api/user/password",
uploadAvatar: "/api/user/avatar",
} as const;
+56
View File
@@ -0,0 +1,56 @@
import { z } from "zod/v4";
// ── Notification ───────────────────────────────────────
export const NotificationSchema = z.object({
id: z.string(),
title: z.string(),
content: z.string(),
type: z.enum(["system", "mention", "task", "alert"]),
read: z.boolean(),
createdAt: z.string(),
createdBy: z.string().nullable().optional(),
});
export type Notification = z.infer<typeof NotificationSchema>;
// ── Audit Log ──────────────────────────────────────────
export const AuditLogSchema = z.object({
id: z.string(),
user: z.string(),
action: z.string(),
resource: z.string(),
resourceId: z.string().nullable(),
detail: z.string().nullable(),
ip: z.string().nullable(),
status: z.enum(["success", "failure"]),
createdAt: z.string(),
});
export type AuditLog = z.infer<typeof AuditLogSchema>;
// ── Profile ────────────────────────────────────────────
export const UpdateProfileSchema = z.object({
realName: z.string().optional(),
nickName: z.string().optional(),
email: z.string().email().optional(),
mobile: z.string().optional(),
department: z.string().optional(),
remark: z.string().optional(),
avatar: z.string().optional(),
});
export type UpdateProfile = z.infer<typeof UpdateProfileSchema>;
export const ChangePasswordSchema = z.object({
oldPassword: z.string().min(1),
newPassword: z.string().min(6),
confirmPassword: z.string().min(6),
}).refine(
(data) => data.newPassword === data.confirmPassword,
{ message: "Passwords do not match", path: ["confirmPassword"] },
);
export type ChangePassword = z.infer<typeof ChangePasswordSchema>;
+53 -1
View File
@@ -7,6 +7,15 @@ export const UserSchema = z.object({
email: z.string().nullable(),
roles: z.array(z.string()),
permissions: z.array(z.string()),
realName: z.string().nullable().optional(),
nickName: z.string().nullable().optional(),
mobile: z.string().nullable().optional(),
department: z.string().nullable().optional(),
role: z.string().nullable().optional(),
status: z.number().int().optional(),
remark: z.string().nullable().optional(),
lastLogin: z.string().nullable().optional(),
createdBy: z.string().nullable().optional(),
});
export type User = z.infer<typeof UserSchema>;
@@ -29,6 +38,43 @@ export const LoginRequestSchema = z.object({
export type LoginRequest = z.infer<typeof LoginRequestSchema>;
export const RegisterRequestSchema = z.object({
username: z.string().min(1),
password: z.string().min(6),
email: z.string().email().optional(),
});
export type RegisterRequest = z.infer<typeof RegisterRequestSchema>;
const optionalNullableString = z
.union([z.string(), z.null(), z.undefined()])
.transform((v) => {
if (v == null) return null;
const t = String(v).trim();
return t === "" ? null : t;
});
export const CreateAdminRequestSchema = z.object({
username: z.string().min(1),
password: z.string().min(6),
email: z.string().email().nullable().optional(),
realName: optionalNullableString.optional(),
nickName: optionalNullableString.optional(),
mobile: optionalNullableString.optional(),
department: optionalNullableString.optional(),
role: optionalNullableString.optional(),
remark: optionalNullableString.optional(),
status: z.number().int().catch(1),
permissions: z.array(z.string()).default([]),
roles: z.array(z.string()).default(["editor"]),
});
export type CreateAdminRequest = z.infer<typeof CreateAdminRequestSchema>;
export const UpdateAdminRequestSchema = CreateAdminRequestSchema.partial();
export type UpdateAdminRequest = z.infer<typeof UpdateAdminRequestSchema>;
export const PermissionsListSchema = z.array(z.string());
export type PermissionsList = z.infer<typeof PermissionsListSchema>;
@@ -121,7 +167,13 @@ const createUserEmailSchema = z
export const CreateUserRequestSchema = z.object({
username: z.string().min(1),
email: createUserEmailSchema,
roles: z.array(z.string()).min(1),
mobile: z
.union([z.string(), z.null(), z.undefined()])
.transform((v) => {
if (v == null) return null;
const t = String(v).trim();
return t === "" ? null : t;
}),
});
export type CreateUserRequest = z.infer<typeof CreateUserRequestSchema>;
@@ -0,0 +1,156 @@
import { useState } from "react";
import { Button, Drawer, Empty, Flex, Input, Tag, Typography, theme } from "antd";
import { Bot, Send, Sparkles } from "lucide-react";
import { useAIStore } from "@/stores/ai";
const { Text } = Typography;
type ChatRole = "user" | "assistant";
type ChatMessage = {
id: string;
role: ChatRole;
content: string;
};
function buildAssistantReply(prompt: string): string {
const lower = prompt.toLowerCase();
if (lower.includes("menu")) {
return "You can edit menu names, icons, visibility and permissions in Menus page, then save to apply globally.";
}
if (lower.includes("permission") || lower.includes("auth")) {
return "Try checking user permissions first, then route mapping in app menu rules, and finally API response payload.";
}
if (lower.includes("dashboard")) {
return "Dashboard now supports timeline blocks. You can add monthly milestones or deployment events to make it useful.";
}
return "I can help with admin, users, menus, and dashboard changes. Tell me what you want to update.";
}
export function GlobalAIChat() {
const { token } = theme.useToken();
const enabled = useAIStore((s) => s.enabled);
const model = useAIStore((s) => s.model);
const [open, setOpen] = useState(false);
const [input, setInput] = useState("");
const [messages, setMessages] = useState<ChatMessage[]>([]);
if (!enabled) return null;
const send = () => {
const text = input.trim();
if (!text) return;
const userMessage: ChatMessage = {
id: `u-${Date.now()}`,
role: "user",
content: text,
};
const assistantMessage: ChatMessage = {
id: `a-${Date.now()}`,
role: "assistant",
content: buildAssistantReply(text),
};
setMessages((prev) => [...prev, userMessage, assistantMessage]);
setInput("");
};
return (
<>
{!open ? (
<Button
type="primary"
shape="round"
size="large"
icon={<Sparkles size={token.fontSize} />}
onClick={() => setOpen(true)}
style={{
position: "fixed",
right: token.marginLG,
bottom: token.marginLG,
zIndex: 1001,
boxShadow: token.boxShadowSecondary,
}}
>
Ask AI
</Button>
) : null}
<Drawer
open={open}
title={
<Flex align="center" gap={token.marginXS}>
<Bot size={token.fontSizeLG} />
<span>Global AI Assistant</span>
</Flex>
}
size="large"
onClose={() => setOpen(false)}
destroyOnClose={false}
>
<Flex vertical gap={token.margin} style={{ height: "100%" }}>
<Tag color="blue" variant="filled" style={{ width: "fit-content" }}>
Context: current admin workspace
</Tag>
<Tag color="purple" variant="filled" style={{ width: "fit-content" }}>
Model: {model}
</Tag>
<Flex
vertical
gap={token.marginSM}
style={{
flex: 1,
overflow: "auto",
border: `1px solid ${token.colorBorderSecondary}`,
borderRadius: token.borderRadius,
padding: token.paddingSM,
}}
>
{messages.length === 0 ? (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="Start a conversation about users, admins, menus, or dashboard."
/>
) : (
messages.map((msg) => (
<Flex
key={msg.id}
justify={msg.role === "user" ? "flex-end" : "flex-start"}
>
<div
style={{
maxWidth: "85%",
padding: `${token.paddingXS}px ${token.paddingSM}px`,
borderRadius: token.borderRadius,
background:
msg.role === "user" ? token.colorPrimaryBgHover : token.colorFillQuaternary,
}}
>
<Text>{msg.content}</Text>
</div>
</Flex>
))
)}
</Flex>
<Input.TextArea
value={input}
rows={3}
placeholder="Ask anything about this admin app..."
onChange={(e) => setInput(e.target.value)}
onPressEnter={(e) => {
if (!e.shiftKey) {
e.preventDefault();
send();
}
}}
/>
<Button type="primary" icon={<Send size={token.fontSize} />} onClick={send}>
Send
</Button>
</Flex>
</Drawer>
</>
);
}
@@ -0,0 +1,119 @@
import { Component } from "react";
import type { ErrorInfo, ReactNode } from "react";
import { Button, Result, Typography, theme, Flex, Collapse } from "antd";
import { reportError, ErrorSeverity } from "@/utils/errorReporter";
export interface ErrorBoundaryProps {
children: ReactNode;
fallback?: (error: Error, reset: () => void) => ReactNode;
onError?: (error: Error, errorInfo: ErrorInfo) => void;
}
interface ErrorBoundaryState {
hasError: boolean;
error: Error | null;
}
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
const componentStack = errorInfo.componentStack ?? undefined;
reportError(error, ErrorSeverity.ERROR, { componentStack });
this.props.onError?.(error, errorInfo);
}
handleReset = () => {
this.setState({ hasError: false, error: null });
};
render() {
if (this.state.hasError && this.state.error) {
if (this.props.fallback) {
return this.props.fallback(this.state.error, this.handleReset);
}
return (
<DefaultErrorFallback
error={this.state.error}
onReset={this.handleReset}
/>
);
}
return this.props.children;
}
}
function DefaultErrorFallback({
error,
onReset,
}: {
error: Error;
onReset: () => void;
}) {
const { token } = theme.useToken();
return (
<Flex
justify="center"
align="center"
style={{
minHeight: "60vh",
padding: token.paddingLG,
}}
>
<Result
status="error"
title="Something went wrong"
subTitle={
<Flex vertical gap={token.marginSM} style={{ maxWidth: 560 }}>
<Typography.Text type="secondary">{error.message}</Typography.Text>
<Collapse
size="small"
items={[
{
key: "stack",
label: "Error details",
children: (
<Typography.Paragraph
code
style={{
fontSize: token.fontSizeSM,
maxHeight: 200,
overflow: "auto",
whiteSpace: "pre-wrap",
wordBreak: "break-all",
margin: 0,
}}
>
{error.stack ?? error.message}
</Typography.Paragraph>
),
},
]}
/>
</Flex>
}
extra={[
<Button type="primary" key="retry" onClick={onReset}>
Try again
</Button>,
<Button
key="reload"
onClick={() => {
window.location.reload();
}}
>
Reload page
</Button>,
]}
/>
</Flex>
);
}
@@ -0,0 +1,105 @@
import { Button, App, Tooltip } from "antd";
import { Download } from "lucide-react";
import { useCallback, useState } from "react";
export interface ExportButtonProps<TData extends Record<string, unknown>> {
/** Data to export */
data: TData[];
/** Column definitions: header label and data key or custom accessor */
columns: {
title: string;
dataIndex?: keyof TData & string;
render?: (record: TData) => string;
}[];
/** Filename without extension */
filename?: string;
/** Button text */
children?: React.ReactNode;
/** Disable button */
disabled?: boolean;
/** Click handler before export (e.g. confirm) */
onBeforeExport?: () => boolean | Promise<boolean>;
}
/**
* Reusable CSV export button.
* Works with any tabular data + column definitions.
*/
export function ExportButton<TData extends Record<string, unknown>>({
data,
columns,
filename = "export",
children = "Export CSV",
disabled,
onBeforeExport,
}: ExportButtonProps<TData>) {
const { message } = App.useApp();
const [exporting, setExporting] = useState(false);
const handleExport = useCallback(async () => {
if (onBeforeExport) {
const ok = await onBeforeExport();
if (!ok) return;
}
setExporting(true);
try {
// Build CSV content
const header = columns.map((c) => `"${c.title}"`).join(",");
const rows = data.map((record) =>
columns
.map((c) => {
let value: unknown;
if (c.render) {
value = c.render(record);
} else if (c.dataIndex) {
value = record[c.dataIndex];
} else {
value = "";
}
// Escape quotes and wrap
const str = String(value ?? "");
return `"${str.replace(/"/g, '""')}"`;
})
.join(","),
);
const csv = [header, ...rows].join("\n");
// Add BOM for Excel UTF-8 compatibility
const bom = "\uFEFF";
const blob = new Blob([bom + csv], { type: "text/csv;charset=utf-8;" });
// Trigger download
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `${filename}.csv`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
message.success(`Exported ${data.length} rows`);
} catch {
message.error("Export failed");
} finally {
setExporting(false);
}
}, [data, columns, filename, onBeforeExport, message]);
return (
<Tooltip title={`Export ${data.length} rows as CSV`}>
<Button
icon={<Download size={14} />}
onClick={handleExport}
loading={exporting}
disabled={disabled || !data.length}
>
{children}
</Button>
</Tooltip>
);
}
@@ -0,0 +1,129 @@
import { Upload, App, theme } from "antd";
import type { UploadProps, UploadFile } from "antd/es/upload/interface";
import { Upload as UploadIcon, Inbox } from "lucide-react";
import { useState } from "react";
export interface FileUploadProps {
/** Upload endpoint URL */
action?: string;
/** Accepted MIME types, e.g. "image/*,.pdf" */
accept?: string;
/** Maximum file size in bytes */
maxSize?: number;
/** Allow multiple files */
multiple?: boolean;
/** Max file count */
maxCount?: number;
/** Custom on-success callback */
onSuccess?: (file: UploadFile, response: unknown) => void;
/** Custom on-error callback */
onError?: (file: UploadFile, error: unknown) => void;
/** Button text */
children?: React.ReactNode;
/** Custom request override (e.g. MSW) */
customRequest?: UploadProps["customRequest"];
/** Control visibility of file list */
showUploadList?: boolean;
}
/**
* Drag-and-drop file upload component built on Ant Design Upload.
* Supports size/type validation and visual feedback.
*/
export function FileUpload({
action = "/api/files/upload",
accept,
maxSize = 10 * 1024 * 1024, // 10 MB
multiple = false,
maxCount = 5,
onSuccess,
onError,
customRequest,
children,
showUploadList = true,
}: FileUploadProps) {
const { message } = App.useApp();
const { token } = theme.useToken();
const [uploading, setUploading] = useState(false);
const beforeUpload = (file: File): boolean | Promise<File> => {
const allowed = accept?.split(",").map((s) => s.trim());
if (allowed?.length && !allowed.some((a) => matchMime(file, a))) {
message.error(`File type not supported: ${file.type || file.name}`);
return false;
}
if (file.size > maxSize) {
const maxMB = (maxSize / (1024 * 1024)).toFixed(1);
message.error(`File exceeds ${maxMB} MB limit: ${file.name}`);
return false;
}
return true;
};
const handleChange: UploadProps["onChange"] = (info) => {
const { file } = info;
if (file.status === "uploading") {
setUploading(true);
}
if (file.status === "done") {
setUploading(false);
onSuccess?.(file, file.response);
}
if (file.status === "error") {
setUploading(false);
onError?.(file, file.error);
}
};
return (
<Upload.Dragger
name="file"
action={action}
accept={accept}
multiple={multiple}
maxCount={maxCount}
beforeUpload={beforeUpload}
onChange={handleChange}
customRequest={customRequest}
showUploadList={showUploadList}
style={{ padding: token.padding }}
>
<p className="ant-upload-drag-icon" style={{ marginBottom: token.marginSM }}>
{uploading ? (
<UploadIcon size={32} style={{ color: token.colorPrimary, opacity: 0.7 }} />
) : (
<Inbox size={32} style={{ color: token.colorTextQuaternary }} />
)}
</p>
<p className="ant-upload-text" style={{ fontWeight: 500 }}>
{children || "Click or drag file to this area to upload"}
</p>
<p className="ant-upload-hint" style={{ color: token.colorTextSecondary }}>
{accept
? `Supported: ${accept}. Max ${(maxSize / (1024 * 1024)).toFixed(1)} MB per file`
: `Max ${(maxSize / (1024 * 1024)).toFixed(1)} MB per file`}
{multiple ? `. Up to ${maxCount} files` : ""}
</p>
</Upload.Dragger>
);
}
function matchMime(file: File, pattern: string): boolean {
if (!pattern) return true;
// Simple glob: "image/*"
if (pattern.endsWith("/*")) {
const base = pattern.slice(0, -2);
return file.type.startsWith(base + "/");
}
// Extension match: ".pdf"
if (pattern.startsWith(".")) {
return file.name.toLowerCase().endsWith(pattern.toLowerCase());
}
// Exact MIME
return file.type === pattern;
}
+270 -95
View File
@@ -1,142 +1,317 @@
import { Layout, Button, Space, theme, Breadcrumb, Flex, Divider, Grid } from "antd";
import type { ItemType } from "antd/es/breadcrumb/Breadcrumb";
/**
* Header — breadcrumb + global actions (notification bell, fullscreen, theme).
*
* ── Architecture ──
* Left: mobile sidebar toggle (lg-) + breadcrumb
* Right: NotificationBell (popover) → Fullscreen → Theme toggle
*
* ── Data flow ──
* breadcrumb ← authStore.menus (flat-tree match) + current route path
* notification badge ← useQuery poll (30s interval)
*/
import {
Badge,
Button,
Divider,
Flex,
Grid,
Layout,
Popover,
Space,
Typography,
theme,
} from "antd";
import { useSettingsStore } from "@/stores/settings";
import { useAuthStore } from "@/stores/auth";
import { Link, useLocation, useMatches } from "@tanstack/react-router";
import { Home, PanelLeft, ShieldAlert, Users } from "lucide-react";
import {
Bell,
Book,
BrainCircuit,
Briefcase,
CheckCheck,
CircleDashed,
Fullscreen,
History,
Home,
Minimize2,
PanelLeft,
ShieldAlert,
ShieldCheck,
SlidersHorizontal,
Star,
User,
Users,
Zap,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { useEffect, useState } from "react";
import type { ReactNode } from "react";
import { Theme } from "@/components/Icon";
import type { MenuItem } from "@/api/schemas";
import type { Notification } from "@/api/schemas-ext";
import { httpClient } from "@/utils/http";
import { NOTIFICATION_ENDPOINTS } from "@/api/notification";
import { useQuery, useQueryClient, useMutation } from "@tanstack/react-query";
const { Header: AntHeader } = Layout;
// ── Route → label mapping (fallback when not found in menu tree) ──
const PATH_LABEL: Record<string, string> = {
"/dashboard": "Dashboard",
"/users": "Users",
"/notifications": "Notifications",
"/logs": "Audit Logs",
"/profile": "Profile",
"/403": "403",
};
// ── Menu icon string → Lucide component ──
const MENU_ICON_MAP: Record<string, LucideIcon> = {
IconLucideHistory: History,
IconLucideLayoutDashboard: Home,
IconLucideUsers: User,
IconLucideUserList: Users,
IconLucideStar: Star,
IconLucideSettings: SlidersHorizontal,
IconLucideBriefcase: Briefcase,
IconLucideBookOpen: Book,
IconLucideSparkles: Zap,
IconLucideShieldCheck: ShieldCheck,
IconLucideBrainCircuit: BrainCircuit,
};
// ── Utilities ──
/** Normalize pathname: strip trailing slash */
function normalizePath(pathname: string): string {
if (pathname === "/") return pathname;
return pathname.replace(/\/+$/, "") || "/";
}
/** Flatten a recursive menu tree into a flat list */
function flattenMenuItems(nodes: MenuItem[]): MenuItem[] {
const result: MenuItem[] = [];
for (const node of nodes) {
result.push(node);
if (node.kind === "group") { result.push(...flattenMenuItems(node.children)); continue; }
if (node.children?.length) result.push(...flattenMenuItems(node.children));
}
return result;
}
/** Find the deepest menu item matching the current path */
function findBestMenuMatch(nodes: MenuItem[], pathname: string): MenuItem | null {
const p = normalizePath(pathname);
const candidates = flattenMenuItems(nodes)
.filter((n): n is Extract<MenuItem, { kind: "item" }> => {
if (n.kind !== "item") return false;
const target = normalizePath(n.path);
return p === target || p.startsWith(`${target}/`);
});
if (!candidates.length) return null;
candidates.sort((a, b) => b.path.length - a.path.length);
return candidates[0];
}
function resolveMenuIcon(icon: string | null): LucideIcon {
return (icon && MENU_ICON_MAP[icon]) || CircleDashed;
}
// ── Props ──
export type HeaderProps = {
/**
* When `false`, breadcrumb is hidden; left `Flex` still uses `flex={1}` so header actions stay right-aligned.
* Routes may also set `staticData: { hideBreadcrumb: true }` (deepest matching route wins).
*/
/** Hide breadcrumb; routes may override via `staticData.hideBreadcrumb` */
showBreadcrumb?: boolean;
};
export function Header({ showBreadcrumb: showBreadcrumbProp = true }: HeaderProps) {
// ── Main component ──
export function Header({ showBreadcrumb: showProp = true }: HeaderProps) {
const toggleSidebar = useSettingsStore((s) => s.toggleSidebar);
const toggleDarkMode = useSettingsStore((s) => s.toggleDarkMode);
const menus = useAuthStore((s) => s.menus);
const location = useLocation();
const matches = useMatches();
const { token } = theme.useToken();
const screens = Grid.useBreakpoint();
const isMobile = !screens.lg;
const [isFullscreen, setIsFullscreen] = useState(false);
const iconSize = token.fontSize;
const crumb = (Icon: LucideIcon, label: ReactNode, linkTo?: "/dashboard") => {
const row = (
<>
<Icon size={iconSize} aria-hidden style={{ flexShrink: 0, opacity: 0.88 }} />
<span>{label}</span>
</>
);
const rowStyle = {
display: "inline-flex" as const,
alignItems: "center" as const,
gap: token.marginXS,
color: "inherit" as const,
};
if (linkTo) {
return (
<Link to={linkTo} style={rowStyle}>
{row}
</Link>
);
}
return <span style={rowStyle}>{row}</span>;
};
useEffect(() => {
const handler = () => setIsFullscreen(Boolean(document.fullscreenElement));
handler();
document.addEventListener("fullscreenchange", handler);
return () => document.removeEventListener("fullscreenchange", handler);
}, []);
// ── Breadcrumb ──
const path = normalizePath(location.pathname);
const segments = path.split("/").filter(Boolean);
const firstSegmentPath = segments.length ? `/${segments[0]}` : "/dashboard";
const leafLabelKey = PATH_LABEL[firstSegmentPath] ?? segments[0] ?? "Dashboard";
const match = findBestMenuMatch(menus, path);
const leafLabel = match?.name ?? PATH_LABEL[path] ?? segments[0] ?? "Dashboard";
const leafIcon: LucideIcon = path === "/403" ? ShieldAlert : match ? resolveMenuIcon(match.icon) : Home;
const leafIcon: LucideIcon =
firstSegmentPath === "/users" ? Users : firstSegmentPath === "/403" ? ShieldAlert : Home;
const leafLabel = leafLabelKey;
const breadcrumbItems: ItemType[] = [];
// Render a breadcrumb link label
const crumb = (IconCmp: LucideIcon, label: ReactNode, link?: "/dashboard") => {
const row = <><IconCmp size={token.fontSize} aria-hidden style={{ flexShrink: 0, opacity: 0.88 }} /><span>{label}</span></>;
const rowStyle = { display: "inline-flex" as const, alignItems: "center" as const, gap: token.marginXS, color: "inherit" as const };
return link ? <Link to={link} style={rowStyle}>{row}</Link> : <span style={rowStyle}>{row}</span>;
};
const onDashboard = path === "/dashboard" || path === "/";
const items = onDashboard
? [{ title: crumb(Home, "Dashboard") }]
: [{ title: crumb(Home, "Dashboard", "/dashboard") }, { title: crumb(leafIcon, leafLabel) },
...(segments.length > 1 ? [{ title: segments.slice(1).join(" / ") }] : [])];
if (onDashboard) {
breadcrumbItems.push({
title: crumb(Home, "Dashboard"),
});
} else {
breadcrumbItems.push({
title: crumb(Home, "Dashboard", "/dashboard"),
});
breadcrumbItems.push({
title: crumb(leafIcon, leafLabel),
});
if (segments.length > 1) {
const tail = segments.slice(1).join(" / ");
if (tail) {
breadcrumbItems.push({ title: tail });
}
}
}
const leafStatic = matches.at(-1)?.staticData as { hideBreadcrumb?: boolean } | undefined;
const hideBreadcrumbFromRoute = leafStatic?.hideBreadcrumb === true;
const showBreadcrumb =
showBreadcrumbProp && !hideBreadcrumbFromRoute && breadcrumbItems.length > 0;
const hideByRoute = (matches.at(-1)?.staticData as { hideBreadcrumb?: boolean } | undefined)?.hideBreadcrumb === true;
const showBc = showProp && !hideByRoute && items.length > 0;
return (
<AntHeader
style={{
background: "transparent",
borderBottom: `1px solid ${token.colorBorderSecondary}`,
padding: `0 ${token.padding}px`,
gap: token.sizeLG,
display: "flex",
}}
>
<Flex align="center" flex={1} style={{ minWidth: 0 }}>
{isMobile ? (
<Button
type="text"
size="small"
onClick={toggleSidebar}
icon={<PanelLeft size={token.size} />}
aria-label="Toggle sidebar"
/>
) : null}
{showBreadcrumb ? (
<AntHeader style={{ background: "transparent", borderBottom: `1px solid ${token.colorBorderSecondary}`, padding: `0 ${token.padding}px`, gap: token.sizeLG, display: "flex" }}>
{/* Left */}
<Flex align="center" flex={1} style={{ minWidth: 0, overflow: "hidden" }}>
{isMobile && <Button type="text" size="small" onClick={toggleSidebar} icon={<PanelLeft size={token.size} />} aria-label="Toggle sidebar" />}
{showBc && (
<>
{isMobile ? <Divider vertical /> : null}
<Breadcrumb items={breadcrumbItems} />
{isMobile && <Divider vertical />}
{isMobile ? (
<Typography.Text ellipsis style={{ fontSize: token.fontSizeLG, fontWeight: 600 }}>
{leafLabel}
</Typography.Text>
) : (
// Desktop: full Breadcrumb component
<Flex align="center" style={{ minWidth: 0 }}>
{/* antd Breadcrumb expects items, but we render them inline for simplicity */}
{items.map((it, i) => (
<span key={i}>
{i > 0 && <span style={{ margin: `0 ${token.marginXXS}px`, color: token.colorTextQuaternary }}>/</span>}
{it.title}
</span>
))}
</Flex>
)}
</>
) : null}
)}
</Flex>
{/* Right */}
<Space>
<Button
type="text"
onClick={toggleDarkMode}
icon={<Theme size={token.size} />}
aria-label="Toggle Theme"
/>
<NotificationTrigger />
<Button type="text" onClick={() => document.fullscreenElement ? void document.exitFullscreen() : void document.documentElement.requestFullscreen()}
icon={isFullscreen ? <Minimize2 size={token.size} /> : <Fullscreen size={token.size} />} aria-label="Toggle fullscreen" />
<Button type="text" onClick={toggleDarkMode} icon={<Theme size={token.size} />} aria-label="Toggle theme" />
</Space>
</AntHeader>
);
}
// ═══════════════════════════════════════════════════════
// Notification Bell (Popover)
// ═══════════════════════════════════════════════════════
/** Inline notification item for the popover dropdown */
function NotifRow({ n, onMark }: { n: Notification; onMark: () => void }) {
const { token } = theme.useToken();
return (
<div
onClick={onMark}
style={{
padding: `${token.paddingXS}px ${token.paddingSM}px`,
cursor: n.read ? "default" : "pointer",
background: n.read ? "transparent" : token.colorFillQuaternary,
borderRadius: token.borderRadius,
borderBottom: `1px solid ${token.colorBorderSecondary}`,
}}
>
<Flex justify="space-between" align="start" gap={token.marginXS}>
<Flex vertical gap={2} style={{ minWidth: 0 }}>
<Typography.Text strong={!n.read} ellipsis style={{ fontSize: token.fontSizeSM }}>
{n.title}
</Typography.Text>
<Typography.Text type="secondary" ellipsis style={{ fontSize: 11 }}>
{n.content}
</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 10 }}>
{new Date(n.createdAt).toLocaleString()}
</Typography.Text>
</Flex>
{!n.read && <Badge status="processing" style={{ flexShrink: 0 }} />}
</Flex>
</div>
);
}
function NotificationTrigger() {
const { token } = theme.useToken();
const qc = useQueryClient();
const [open, setOpen] = useState(false);
// Poll unread-only + latest 5 for popover (lightweight)
const { data: unreadList = [] } = useQuery({
queryKey: ["notifications", "bell-unread"],
queryFn: () =>
httpClient.get<Notification[]>(NOTIFICATION_ENDPOINTS.list, {
params: { unreadOnly: "true" },
}),
refetchInterval: 30_000,
});
const unreadCount = unreadList.length;
// Load full list when popover opens (for any read ones)
const { data: fullList = [] } = useQuery({
queryKey: ["notifications", "bell-all"],
queryFn: () => httpClient.get<Notification[]>(NOTIFICATION_ENDPOINTS.list),
enabled: open,
});
const merged = open
? [...fullList].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()).slice(0, 5)
: [];
const markAsRead = useMutation({
mutationFn: (id: string) => httpClient.put(NOTIFICATION_ENDPOINTS.markRead(id)),
onSuccess: () => { void qc.invalidateQueries({ queryKey: ["notifications"] }); },
});
const markAll = useMutation({
mutationFn: () => httpClient.put(NOTIFICATION_ENDPOINTS.markAllRead),
onSuccess: () => { void qc.invalidateQueries({ queryKey: ["notifications"] }); },
});
const content = (
<div style={{ width: 360, maxHeight: 440, overflow: "hidden", display: "flex", flexDirection: "column" }}>
<Flex justify="space-between" align="center" style={{ padding: `${token.paddingSM}px ${token.padding}px`, borderBottom: `1px solid ${token.colorBorderSecondary}` }}>
<Typography.Text strong>Notifications</Typography.Text>
{unreadCount > 0 && (
<Button type="link" size="small" icon={<CheckCheck size={token.fontSize} />}
loading={markAll.isPending} onClick={() => markAll.mutate()}>
Mark all read
</Button>
)}
</Flex>
<div style={{ flex: 1, overflow: "auto" }}>
{merged.length === 0 ? (
<Flex justify="center" style={{ padding: token.paddingMD * 2 }}>
<Typography.Text type="secondary">No notifications</Typography.Text>
</Flex>
) : (
merged.map((n) => <NotifRow key={n.id} n={n} onMark={() => !n.read && markAsRead.mutate(n.id)} />)
)}
</div>
<Flex justify="center" style={{ padding: token.paddingSM, borderTop: `1px solid ${token.colorBorderSecondary}` }}>
<Link to="/notifications" onClick={() => setOpen(false)}>
<Typography.Link>View All Notifications</Typography.Link>
</Link>
</Flex>
</div>
);
return (
<Popover content={content} trigger="click" open={open} onOpenChange={setOpen} placement="bottomRight">
<Badge count={unreadCount} size="small" offset={[-2, 4]}>
<Button type="text" icon={<Bell size={token.size} />} aria-label="Notifications" />
</Badge>
</Popover>
);
}
@@ -2,6 +2,7 @@ import { Layout, theme, Flex } from "antd";
import { Outlet } from "@tanstack/react-router";
import { Sidebar } from "../Sidebar";
import { Header } from "../Header";
import { GlobalAIChat } from "@/components/AIChat/GlobalAIChat";
const { Content } = Layout;
@@ -34,6 +35,7 @@ export function MainLayout() {
<Outlet />
</Content>
</Flex>
<GlobalAIChat />
</Layout>
);
}
@@ -8,11 +8,13 @@ import {
Folder,
Home,
PanelLeft,
ShieldCheck,
SlidersHorizontal,
Star,
User,
Users,
Zap,
BrainCircuit,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { APP_BRAND_NAME, APP_FAVICON_SRC } from "@/utils/constants";
@@ -30,6 +32,13 @@ const MENU_LABELS: Record<string, string> = {
Projects: "Projects",
Dashboard: "Dashboard",
Users: "Users",
Admins: "Admins",
Menus: "Menus",
Settings: "Settings",
Notifications: "Notifications",
Logs: "Audit Logs",
Profile: "Profile",
"AI Settings": "AI Settings",
"Design Engineering": "Design Engineering",
"Sales & Marketing": "Sales & Marketing",
};
@@ -53,6 +62,8 @@ const MENU_ICON_MAP: Record<string, LucideIcon> = {
IconLucideBookOpen: Book,
IconLucideFolderKanban: Folder,
IconLucideSparkles: Zap,
IconLucideShieldCheck: ShieldCheck,
IconLucideBrainCircuit: BrainCircuit,
};
function renderMenuIcon(icon: string | null, size = 16) {
+7 -7
View File
@@ -3,7 +3,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
type MutationLifecycle<TValues> = {
onMutate?: (values: TValues) => void;
onSuccess?: (values: TValues) => void;
onError?: (values: TValues) => void;
onError?: (values: TValues, error: unknown) => void;
};
type UseResourceCRUDOptions<TListData, TCreateValues, TUpdateValues> = {
@@ -39,8 +39,8 @@ export function useResourceCRUD<TListData, TCreateValues, TUpdateValues>(
void queryClient.invalidateQueries({ queryKey: options.invalidateKey });
options.createLifecycle?.onSuccess?.(values);
},
onError: (_error, values) => {
options.createLifecycle?.onError?.(values);
onError: (error, values) => {
options.createLifecycle?.onError?.(values, error);
},
});
@@ -53,8 +53,8 @@ export function useResourceCRUD<TListData, TCreateValues, TUpdateValues>(
void queryClient.invalidateQueries({ queryKey: options.invalidateKey });
options.updateLifecycle?.onSuccess?.(values);
},
onError: (_error, values) => {
options.updateLifecycle?.onError?.(values);
onError: (error, values) => {
options.updateLifecycle?.onError?.(values, error);
},
});
@@ -67,8 +67,8 @@ export function useResourceCRUD<TListData, TCreateValues, TUpdateValues>(
void queryClient.invalidateQueries({ queryKey: options.invalidateKey });
options.deleteLifecycle?.onSuccess?.(id);
},
onError: (_error, id) => {
options.deleteLifecycle?.onError?.(id);
onError: (error, id) => {
options.deleteLifecycle?.onError?.(id, error);
},
});
+16 -1
View File
@@ -4,6 +4,10 @@ import { RouterProvider, createRouter } from "@tanstack/react-router";
import { routeTree } from "./routeTree.gen";
import { useAuthStore } from "./stores/auth";
import { fetchSessionAndApplyToStore } from "./utils/session";
import { installGlobalErrorHandlers } from "./utils/errorReporter";
// 全局兜底异常捕获(window.onerror / unhandledrejection)
installGlobalErrorHandlers();
const router = createRouter({ routeTree });
@@ -13,11 +17,22 @@ declare module "@tanstack/react-router" {
}
}
async function enableMocking() {
const enableMockInBuild = import.meta.env.VITE_ENABLE_MOCK === "true";
if (!import.meta.env.DEV && !enableMockInBuild) return;
const { worker } = await import("./mocks/browser");
return worker.start({ onUnhandledRequest: "bypass" });
worker.start({
onUnhandledRequest(_, print) {
if (import.meta.env.DEV) {
// 开发环境下 warn
print.warning();
} else {
// 生产/构建 mock 环境下 error
print.error();
}
},
});
}
enableMocking()
+462 -22
View File
@@ -1,26 +1,466 @@
import type { User } from "@/api/schemas";
import type { MenuItem, User } from "@/api/schemas";
import { APP_MENU_TREE } from "@/utils/appMenu";
import { vercelAvatarUrl } from "./utils";
/** Demo logins look like real org accounts (first.last @ northstar.io). Index 0 stays `admin` for mock login. */
const MOCK_IDENTITIES: ReadonlyArray<[string, string]> = [
["admin", "ops.admin@northstar.io"],
["zhao.ming", "zhao.ming@northstar.io"],
["sarah.chen", "sarah.chen@northstar.io"],
["james.park", "james.park@northstar.io"],
["emma.garcia", "emma.garcia@northstar.io"],
["ryan.kim", "ryan.kim@northstar.io"],
["olivia.tanaka", "olivia.tanaka@northstar.io"],
["liam.patel", "liam.patel@northstar.io"],
["ava.nguyen", "ava.nguyen@northstar.io"],
["noah.berg", "noah.berg@northstar.io"],
["mia.silva", "mia.silva@northstar.io"],
const ADMIN_PERMISSIONS = [
"user:view",
"user:create",
"user:edit",
"user:delete",
"admin:view",
"admin:create",
"admin:edit",
"admin:delete",
];
const EDITOR_PERMISSIONS = ["user:view"];
export interface MockAuthUser extends User {
password: string;
}
function permissionsFromRoles(roles: string[]): string[] {
return roles.includes("admin") ? [...ADMIN_PERMISSIONS] : [...EDITOR_PERMISSIONS];
}
function uniquePermissions(permissions: string[]): string[] {
return [...new Set(permissions)];
}
function createMockAuthUser(seed: {
id: string;
username: string;
password: string;
email: string | null;
roles: string[];
realName?: string | null;
nickName?: string | null;
mobile?: string | null;
department?: string | null;
role?: string | null;
status?: number;
remark?: string | null;
createdBy?: string | null;
}): MockAuthUser {
return {
id: seed.id,
username: seed.username,
password: seed.password,
avatar: vercelAvatarUrl(seed.username),
email: seed.email,
roles: seed.roles,
permissions: permissionsFromRoles(seed.roles),
realName: seed.realName ?? null,
nickName: seed.nickName ?? null,
mobile: seed.mobile ?? null,
department: seed.department ?? null,
role: seed.role ?? null,
status: seed.status ?? 1,
remark: seed.remark ?? null,
lastLogin: null,
createdBy: seed.createdBy ?? "system",
};
}
const authUsers: MockAuthUser[] = [
createMockAuthUser({
id: "1",
username: "admin",
password: "admin",
email: "ops.admin@northstar.io",
roles: ["admin"],
realName: "Li Si",
nickName: "Super Admin",
mobile: "13800000000",
department: "Operations",
role: "Administrator",
remark: "Full access account",
}),
createMockAuthUser({
id: "2",
username: "guest",
password: "guest",
email: "guest@northstar.io",
roles: ["editor"],
realName: "Guest User",
nickName: "Guest",
mobile: "15100000000",
department: "External",
role: "Viewer",
remark: "Read-only account",
}),
createMockAuthUser({
id: "3",
username: "zhao.ming",
password: "zhao1234",
email: "zhao.ming@northstar.io",
roles: ["editor"],
realName: "Zhao Ming",
nickName: "Ming",
department: "Product",
role: "Editor",
}),
];
export const MOCK_USERS: User[] = MOCK_IDENTITIES.map(([username, email], i) => ({
id: String(i + 1),
username,
avatar: vercelAvatarUrl(username),
email,
roles: i === 0 ? ["admin"] : ["editor"],
permissions: i === 0 ? ["user:view", "user:create", "user:edit", "user:delete"] : ["user:view"],
}));
let nextId = authUsers.length + 1;
const usersData: User[] = [
{
id: "u1",
username: "olivia",
avatar: vercelAvatarUrl("olivia"),
email: "olivia@northstar.io",
roles: ["editor"],
permissions: ["user:view"],
realName: "Olivia Martin",
nickName: "Olivia",
mobile: "13600000001",
department: "Product",
role: "Product Manager",
status: 1,
remark: null,
lastLogin: null,
createdBy: "seed",
},
{
id: "u2",
username: "jackson",
avatar: vercelAvatarUrl("jackson"),
email: "jackson@northstar.io",
roles: ["editor"],
permissions: ["user:view"],
realName: "Jackson Lee",
nickName: "JL",
mobile: "13600000002",
department: "Sales",
role: "Sales Lead",
status: 1,
remark: null,
lastLogin: null,
createdBy: "seed",
},
{
id: "u3",
username: "isabella",
avatar: vercelAvatarUrl("isabella"),
email: "isabella@northstar.io",
roles: ["editor"],
permissions: ["user:view"],
realName: "Isabella Nguyen",
nickName: "Isa",
mobile: "13600000003",
department: "Marketing",
role: "Campaign Specialist",
status: 1,
remark: null,
lastLogin: null,
createdBy: "seed",
},
];
const adminsData: User[] = [
{
id: "a1",
username: "admin",
avatar: vercelAvatarUrl("admin"),
email: "ops.admin@northstar.io",
roles: ["admin"],
permissions: ["*"],
realName: "Li Si",
nickName: "Super Admin",
mobile: "13800000000",
department: "Operations",
role: "Administrator",
status: 1,
remark: "Full access account",
lastLogin: null,
createdBy: "system",
},
{
id: "a2",
username: "guest-admin",
avatar: vercelAvatarUrl("guest-admin"),
email: "guest.admin@northstar.io",
roles: ["editor"],
permissions: ["admin:view"],
realName: "Guest Admin",
nickName: "GA",
mobile: "13800000001",
department: "Support",
role: "Support Admin",
status: 1,
remark: null,
lastLogin: null,
createdBy: "system",
},
];
let nextUserId = usersData.length + 1;
let nextAdminId = adminsData.length + 1;
let menusData: MenuItem[] = JSON.parse(JSON.stringify(APP_MENU_TREE)) as MenuItem[];
function stripPassword(user: MockAuthUser): User {
const { password: _password, ...rest } = user;
return rest;
}
export function issueMockTokens(userId: string) {
return {
accessToken: `mock-access-token:${userId}`,
refreshToken: `mock-refresh-token:${userId}`,
};
}
export function parseUserIdFromAccessToken(authHeader: string | null): string | null {
if (!authHeader?.startsWith("Bearer ")) return null;
const token = authHeader.slice(7);
if (!token.startsWith("mock-access-token:")) return null;
return token.slice("mock-access-token:".length) || null;
}
export function parseUserIdFromRefreshToken(token: string | null | undefined): string | null {
if (!token || !token.startsWith("mock-refresh-token:")) return null;
return token.slice("mock-refresh-token:".length) || null;
}
export function listUsers(): User[] {
return usersData.map((u) => ({ ...u }));
}
export function listAdmins(): User[] {
return adminsData.map((u) => ({ ...u }));
}
export function findAdminById(adminId: string): User | undefined {
return adminsData.find((u) => u.id === adminId);
}
export function findAdminByUsername(username: string): User | undefined {
return adminsData.find((u) => u.username.toLowerCase() === username.trim().toLowerCase());
}
export function findUserById(userId: string): MockAuthUser | undefined {
return authUsers.find((u) => u.id === userId);
}
export function findUserByUsername(username: string): MockAuthUser | undefined {
return authUsers.find((u) => u.username.toLowerCase() === username.trim().toLowerCase());
}
export function authenticateUser(username: string, password: string): MockAuthUser | undefined {
const user = findUserByUsername(username);
if (!user || user.password !== password || user.status !== 1) return undefined;
user.lastLogin = new Date().toISOString();
return user;
}
export function registerUser(payload: {
username: string;
password: string;
email?: string;
}): MockAuthUser {
const user = createMockAuthUser({
id: String(nextId++),
username: payload.username,
password: payload.password,
email: payload.email ?? null,
roles: ["editor"],
realName: payload.username,
nickName: payload.username,
department: "Community",
role: "Editor",
remark: "Registered from mock auth endpoint",
createdBy: "self-register",
});
user.lastLogin = new Date().toISOString();
authUsers.push(user);
return user;
}
export function createUser(payload: {
username: string;
email: string | null;
mobile: string | null;
}): User {
const user: User = {
id: `u${nextUserId++}`,
username: payload.username,
avatar: vercelAvatarUrl(payload.username),
email: payload.email,
roles: ["editor"],
permissions: permissionsFromRoles(["editor"]),
realName: payload.username,
nickName: payload.username,
mobile: payload.mobile,
department: "Operations",
role: "User",
status: 1,
remark: null,
lastLogin: null,
createdBy: "users-page",
};
usersData.push(user);
return { ...user };
}
export function createAdmin(payload: {
username: string;
password: string;
email: string | null;
roles: string[];
permissions: string[];
realName?: string | null;
nickName?: string | null;
mobile?: string | null;
department?: string | null;
role?: string | null;
remark?: string | null;
status?: number;
}): User {
const roles = payload.roles.length ? payload.roles : ["editor"];
const derivedPermissions = payload.permissions.length
? uniquePermissions(payload.permissions)
: permissionsFromRoles(roles);
const user: User = {
id: `a${nextAdminId++}`,
username: payload.username,
avatar: vercelAvatarUrl(payload.username),
email: payload.email,
roles,
permissions: derivedPermissions,
realName: payload.realName ?? payload.username,
nickName: payload.nickName ?? payload.username,
mobile: payload.mobile ?? null,
department: payload.department ?? "Administration",
role: payload.role ?? (roles.includes("admin") ? "Administrator" : "Editor"),
remark: payload.remark ?? null,
status: payload.status ?? 1,
lastLogin: null,
createdBy: "admins-page",
};
adminsData.push(user);
const authUser = createMockAuthUser({
id: user.id,
username: user.username,
password: payload.password,
email: user.email,
roles: user.roles,
realName: user.realName,
nickName: user.nickName,
mobile: user.mobile,
department: user.department,
role: user.role,
status: user.status,
remark: user.remark,
createdBy: user.createdBy,
});
authUser.permissions = user.permissions;
authUsers.push(authUser);
return { ...user };
}
export function updateUser(userId: string, payload: Partial<User>): User | undefined {
const user = usersData.find((u) => u.id === userId);
if (!user) return undefined;
if (payload.username != null) {
user.username = payload.username;
user.avatar = vercelAvatarUrl(payload.username);
}
if (payload.email !== undefined) user.email = payload.email;
if (payload.roles) {
user.roles = payload.roles;
user.permissions = permissionsFromRoles(payload.roles);
}
if (payload.permissions) user.permissions = payload.permissions;
if (payload.realName !== undefined) user.realName = payload.realName ?? null;
if (payload.nickName !== undefined) user.nickName = payload.nickName ?? null;
if (payload.mobile !== undefined) user.mobile = payload.mobile ?? null;
if (payload.department !== undefined) user.department = payload.department ?? null;
if (payload.role !== undefined) user.role = payload.role ?? null;
if (payload.status !== undefined) user.status = payload.status;
if (payload.remark !== undefined) user.remark = payload.remark ?? null;
return { ...user };
}
export function updateAdmin(
userId: string,
payload: Partial<User> & { password?: string | null },
): User | undefined {
const user = adminsData.find((u) => u.id === userId);
if (!user) return undefined;
if (payload.username != null) {
user.username = payload.username;
user.avatar = vercelAvatarUrl(payload.username);
}
if (payload.email !== undefined) user.email = payload.email;
if (payload.roles) {
user.roles = payload.roles;
user.permissions = permissionsFromRoles(payload.roles);
}
if (payload.realName !== undefined) user.realName = payload.realName ?? null;
if (payload.nickName !== undefined) user.nickName = payload.nickName ?? null;
if (payload.mobile !== undefined) user.mobile = payload.mobile ?? null;
if (payload.department !== undefined) user.department = payload.department ?? null;
if (payload.role !== undefined) user.role = payload.role ?? null;
if (payload.status !== undefined) user.status = payload.status;
if (payload.remark !== undefined) user.remark = payload.remark ?? null;
if (payload.permissions) {
user.permissions = uniquePermissions(payload.permissions);
}
if (payload.roles && !payload.permissions) {
user.permissions = permissionsFromRoles(payload.roles);
}
const authUser = authUsers.find((u) => u.id === userId);
if (authUser) {
authUser.username = user.username;
authUser.avatar = user.avatar;
authUser.email = user.email;
authUser.roles = user.roles;
authUser.permissions = user.permissions;
authUser.realName = user.realName;
authUser.nickName = user.nickName;
authUser.mobile = user.mobile;
authUser.department = user.department;
authUser.role = user.role;
authUser.status = user.status;
authUser.remark = user.remark;
if (payload.password && payload.password.trim().length > 0) {
authUser.password = payload.password;
}
}
return { ...user };
}
export function deleteAdmin(userId: string): boolean {
const idx = adminsData.findIndex((u) => u.id === userId);
if (idx === -1) return false;
adminsData.splice(idx, 1);
const authIdx = authUsers.findIndex((u) => u.id === userId);
if (authIdx !== -1) {
authUsers.splice(authIdx, 1);
}
return true;
}
export function deleteUser(userId: string): boolean {
const idx = usersData.findIndex((u) => u.id === userId);
if (idx === -1) return false;
usersData.splice(idx, 1);
return true;
}
export function toPublicUser(user: MockAuthUser): User {
return stripPassword(user);
}
export function listMenus(): MenuItem[] {
return JSON.parse(JSON.stringify(menusData)) as MenuItem[];
}
export function saveMenus(nextMenus: MenuItem[]): MenuItem[] {
menusData = JSON.parse(JSON.stringify(nextMenus)) as MenuItem[];
return listMenus();
}
+154
View File
@@ -0,0 +1,154 @@
import { http } from "msw";
import {
createAdmin,
deleteAdmin,
findAdminById,
findAdminByUsername,
findUserById,
findUserByUsername,
listAdmins,
listMenus,
parseUserIdFromAccessToken,
saveMenus,
updateAdmin,
} from "../data";
import { filterUsers, paginateList, parsePaginationParams } from "../utils";
import { withDelay, successResponse, errorResponse, ERROR_CODES } from "../createHandler";
function hasPoint(authHeader: string | null, point: string): boolean {
const userId = parseUserIdFromAccessToken(authHeader);
const user = userId ? findUserById(userId) : undefined;
if (!user) return false;
return user.permissions.includes("*") || user.permissions.includes(point);
}
export const adminHandlers = [
http.get("/api/admins", async ({ request }) => {
await withDelay(180);
if (!hasPoint(request.headers.get("authorization"), "admin:view")) {
return errorResponse(ERROR_CODES.FORBIDDEN, "Forbidden");
}
const url = new URL(request.url);
const { limit, offset } = parsePaginationParams(url.searchParams);
const keyword = url.searchParams.get("keyword") ?? "";
const role = url.searchParams.get("role") ?? "";
const filtered = filterUsers(listAdmins(), { keyword, role });
const list = paginateList(filtered, limit, offset);
return successResponse({ list, total: filtered.length });
}),
http.get("/api/admins/:id", async ({ params, request }) => {
await withDelay(120);
if (!hasPoint(request.headers.get("authorization"), "admin:view")) {
return errorResponse(ERROR_CODES.FORBIDDEN, "Forbidden");
}
const admin = findAdminById(String(params.id));
if (!admin) {
return errorResponse(ERROR_CODES.NOT_FOUND, "Admin not found");
}
return successResponse(admin);
}),
http.post("/api/admins", async ({ request }) => {
await withDelay(240);
if (!hasPoint(request.headers.get("authorization"), "admin:create")) {
return errorResponse(ERROR_CODES.FORBIDDEN, "Forbidden");
}
const body = (await request.json()) as Record<string, unknown>;
const username = String(body.username ?? "").trim();
const password = String(body.password ?? "");
if (!username || password.length < 6) {
return errorResponse(ERROR_CODES.BAD_REQUEST, "Username and password are required");
}
if (findAdminByUsername(username) || findUserByUsername(username)) {
return errorResponse(ERROR_CODES.BAD_REQUEST, "Username already exists");
}
const created = createAdmin({
username,
password,
email: typeof body.email === "string" ? body.email : null,
roles: Array.isArray(body.roles) ? (body.roles as string[]) : ["editor"],
permissions: Array.isArray(body.permissions) ? (body.permissions as string[]) : [],
realName: typeof body.realName === "string" ? body.realName : null,
nickName: typeof body.nickName === "string" ? body.nickName : null,
mobile: typeof body.mobile === "string" ? body.mobile : null,
department: typeof body.department === "string" ? body.department : null,
role: typeof body.role === "string" ? body.role : null,
remark: typeof body.remark === "string" ? body.remark : null,
status: typeof body.status === "number" ? body.status : 1,
});
return successResponse(created);
}),
http.put("/api/admins/:id", async ({ params, request }) => {
await withDelay(220);
if (!hasPoint(request.headers.get("authorization"), "admin:edit")) {
return errorResponse(ERROR_CODES.FORBIDDEN, "Forbidden");
}
const body = (await request.json()) as Record<string, unknown>;
const nextUsername = typeof body.username === "string" ? body.username.trim() : undefined;
const existingAdmin = nextUsername ? findAdminByUsername(nextUsername) : undefined;
const existingAuth = nextUsername ? findUserByUsername(nextUsername) : undefined;
if (
(existingAdmin && existingAdmin.id !== String(params.id)) ||
(existingAuth && existingAuth.id !== String(params.id))
) {
return errorResponse(ERROR_CODES.BAD_REQUEST, "Username already exists");
}
const updated = updateAdmin(String(params.id), {
username: nextUsername,
email: typeof body.email === "string" || body.email === null ? (body.email as string | null) : undefined,
roles: Array.isArray(body.roles) ? (body.roles as string[]) : undefined,
permissions: Array.isArray(body.permissions) ? (body.permissions as string[]) : undefined,
realName: typeof body.realName === "string" || body.realName === null ? (body.realName as string | null) : undefined,
nickName: typeof body.nickName === "string" || body.nickName === null ? (body.nickName as string | null) : undefined,
mobile: typeof body.mobile === "string" || body.mobile === null ? (body.mobile as string | null) : undefined,
department:
typeof body.department === "string" || body.department === null
? (body.department as string | null)
: undefined,
role: typeof body.role === "string" || body.role === null ? (body.role as string | null) : undefined,
remark: typeof body.remark === "string" || body.remark === null ? (body.remark as string | null) : undefined,
status: typeof body.status === "number" ? body.status : undefined,
password: typeof body.password === "string" && body.password.length > 0 ? body.password : undefined,
});
if (!updated) {
return errorResponse(ERROR_CODES.NOT_FOUND, "Admin not found");
}
return successResponse(updated);
}),
http.delete("/api/admins/:id", async ({ params, request }) => {
await withDelay(180);
if (!hasPoint(request.headers.get("authorization"), "admin:delete")) {
return errorResponse(ERROR_CODES.FORBIDDEN, "Forbidden");
}
const ok = deleteAdmin(String(params.id));
if (!ok) {
return errorResponse(ERROR_CODES.NOT_FOUND, "Admin not found");
}
return successResponse(null);
}),
http.get("/api/menus", async ({ request }) => {
await withDelay(120);
if (!hasPoint(request.headers.get("authorization"), "admin:view")) {
return errorResponse(ERROR_CODES.FORBIDDEN, "Forbidden");
}
return successResponse(listMenus());
}),
http.put("/api/menus", async ({ request }) => {
await withDelay(220);
if (!hasPoint(request.headers.get("authorization"), "admin:edit")) {
return errorResponse(ERROR_CODES.FORBIDDEN, "Forbidden");
}
const body = (await request.json()) as { menus?: unknown };
if (!Array.isArray(body?.menus)) {
return errorResponse(ERROR_CODES.BAD_REQUEST, "Invalid menus payload");
}
return successResponse(saveMenus(body.menus as any));
}),
];
+58 -14
View File
@@ -1,5 +1,14 @@
import { http } from "msw";
import { MOCK_USERS } from "../data";
import {
authenticateUser,
findUserById,
findUserByUsername,
issueMockTokens,
parseUserIdFromAccessToken,
parseUserIdFromRefreshToken,
registerUser,
toPublicUser,
} from "../data";
import { withDelay, successResponse, errorResponse, ERROR_CODES } from "../createHandler";
export const authHandlers = [
@@ -9,29 +18,64 @@ export const authHandlers = [
username: string;
password: string;
};
if (body.username === "admin" && body.password === "admin") {
return successResponse({
accessToken: "mock-access-token",
refreshToken: "mock-refresh-token",
});
const user = authenticateUser(body.username, body.password);
if (user) {
return successResponse(issueMockTokens(user.id));
}
return errorResponse(ERROR_CODES.INVALID_CREDENTIALS, "Invalid username or password");
}),
http.post("/api/auth/refresh", async () => {
http.post("/api/auth/register", async ({ request }) => {
await withDelay(350);
const body = (await request.json()) as {
username?: string;
password?: string;
email?: string;
};
const username = String(body.username ?? "").trim();
const password = String(body.password ?? "");
const email = typeof body.email === "string" ? body.email.trim() : undefined;
if (!username || password.length < 6) {
return errorResponse(ERROR_CODES.BAD_REQUEST, "Invalid register payload");
}
if (findUserByUsername(username)) {
return errorResponse(ERROR_CODES.BAD_REQUEST, "Username already exists");
}
const user = registerUser({ username, password, email });
return successResponse(issueMockTokens(user.id));
}),
http.post("/api/auth/refresh", async ({ request }) => {
await withDelay(100);
return successResponse({
accessToken: "mock-new-access-token",
refreshToken: "mock-new-refresh-token",
});
const body = (await request.json().catch(() => ({}))) as { refreshToken?: string };
const userId = parseUserIdFromRefreshToken(body.refreshToken);
if (!userId || !findUserById(userId)) {
return errorResponse(ERROR_CODES.UNAUTHORIZED, "Invalid refresh token");
}
return successResponse(issueMockTokens(userId));
}),
http.post("/api/auth/logout", () => successResponse(null)),
http.get("/api/auth/user", () => {
const { permissions: _permissions, ...userWithoutPermissions } = MOCK_USERS[0]!;
http.get("/api/auth/user", ({ request }) => {
const userId = parseUserIdFromAccessToken(request.headers.get("authorization"));
const user = userId ? findUserById(userId) : undefined;
if (!user) {
return errorResponse(ERROR_CODES.UNAUTHORIZED, "Unauthorized");
}
const { permissions: _permissions, ...userWithoutPermissions } = toPublicUser(user);
return successResponse(userWithoutPermissions);
}),
http.get("/api/auth/permissions", () => successResponse(MOCK_USERS[0]!.permissions)),
http.get("/api/auth/permissions", ({ request }) => {
const userId = parseUserIdFromAccessToken(request.headers.get("authorization"));
const user = userId ? findUserById(userId) : undefined;
if (!user) {
return errorResponse(ERROR_CODES.UNAUTHORIZED, "Unauthorized");
}
return successResponse(user.permissions);
}),
];
+10 -1
View File
@@ -1,4 +1,13 @@
import { authHandlers } from "./auth";
import { adminHandlers } from "./admin";
import { userHandlers } from "./user";
import { notificationHandlers } from "./notification";
import { logHandlers } from "./log";
export const handlers = [...authHandlers, ...userHandlers];
export const handlers = [
...authHandlers,
...adminHandlers,
...userHandlers,
...notificationHandlers,
...logHandlers,
];
+57
View File
@@ -0,0 +1,57 @@
import { http, HttpResponse, delay } from "msw";
import type { AuditLog } from "@/api/schemas-ext";
const ACTIONS = ["create", "update", "delete", "login", "logout", "export", "import", "approve"];
const RESOURCES = ["user", "role", "menu", "file", "system", "notification", "report"];
const USERS = ["admin", "manager", "alice", "bob", "john_doe", "system"];
function genLogs(): AuditLog[] {
const logs: AuditLog[] = [];
for (let i = 0; i < 42; i++) {
const action = ACTIONS[i % ACTIONS.length];
const resource = RESOURCES[i % RESOURCES.length];
logs.push({
id: `log-${i}`,
user: USERS[i % USERS.length],
action,
resource,
resourceId: `res-${(i % 20) + 1}`,
detail: `${action} ${resource} record #${(i % 20) + 1}`,
ip: `192.168.1.${(i % 254) + 1}`,
status: i % 7 === 0 ? "failure" : "success",
createdAt: new Date(Date.now() - (i * 7200000)).toISOString(),
});
}
return logs;
}
const logs = genLogs();
export const logHandlers = [
http.get("/api/logs", async ({ request }) => {
await delay(300);
const url = new URL(request.url);
const user = url.searchParams.get("user");
const action = url.searchParams.get("action");
const status = url.searchParams.get("status");
const keyword = url.searchParams.get("keyword");
let list = [...logs];
if (user) list = list.filter((l) => l.user === user);
if (action) list = list.filter((l) => l.action === action);
if (status) list = list.filter((l) => l.status === status);
if (keyword) {
const kw = keyword.toLowerCase();
list = list.filter(
(l) =>
l.detail?.toLowerCase().includes(kw) ||
l.resource.toLowerCase().includes(kw) ||
l.user.toLowerCase().includes(kw),
);
}
// Return list directly (standard pattern for list APIs)
return HttpResponse.json(list);
}),
];
@@ -0,0 +1,122 @@
import { http, HttpResponse, delay } from "msw";
import type { Notification } from "@/api/schemas-ext";
let notifications: Notification[] = [
{
id: "n1",
title: "System update available",
content: "Version 6.1.0 is now available with performance improvements and bug fixes.",
type: "system",
read: false,
createdAt: new Date(Date.now() - 3600000).toISOString(),
createdBy: "System",
},
{
id: "n2",
title: "New user registered",
content: "User 'john_doe' has created an account and is pending approval.",
type: "mention",
read: false,
createdAt: new Date(Date.now() - 7200000).toISOString(),
createdBy: "admin",
},
{
id: "n3",
title: "Task assigned: Review PR #42",
content: "You have been assigned to review 'feat: add export functionality'.",
type: "task",
read: false,
createdAt: new Date(Date.now() - 10800000).toISOString(),
createdBy: "manager",
},
{
id: "n4",
title: "Backup completed",
content: "Daily database backup completed successfully. Size: 2.3 GB.",
type: "system",
read: true,
createdAt: new Date(Date.now() - 86400000).toISOString(),
createdBy: "System",
},
{
id: "n5",
title: "Security alert: Multiple failed logins",
content: "Detected 15 failed login attempts from IP 192.168.1.100 in the last 5 minutes.",
type: "alert",
read: true,
createdAt: new Date(Date.now() - 86400000 * 2).toISOString(),
createdBy: "System",
},
{
id: "n6",
title: "Comment on 'Dashboard Redesign'",
content: "Alice commented: 'The new layout looks great! Let's discuss the chart placement.'",
type: "mention",
read: false,
createdAt: new Date(Date.now() - 86400000 * 3).toISOString(),
createdBy: "alice",
},
{
id: "n7",
title: "Monthly report ready",
content: "The April 2026 analytics report has been generated and is ready for review.",
type: "system",
read: true,
createdAt: new Date(Date.now() - 86400000 * 5).toISOString(),
createdBy: "System",
},
{
id: "n8",
title: "Deployment to production",
content: "Release v6.0.0 has been deployed to production environment at 14:30 UTC.",
type: "system",
read: true,
createdAt: new Date(Date.now() - 86400000 * 7).toISOString(),
createdBy: "admin",
},
];
export const notificationHandlers = [
http.get("/api/notifications", async ({ request }) => {
await delay(600);
const url = new URL(request.url);
const type = url.searchParams.get("type");
const unreadOnly = url.searchParams.get("unreadOnly") === "true";
let list = notifications.sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
);
if (type) {
list = list.filter((n) => n.type === type);
}
if (unreadOnly) {
list = list.filter((n) => !n.read);
}
return HttpResponse.json({
code: 0,
data: list,
message: "OK",
});
}),
http.put("/api/notifications/:id/read", async ({ params }) => {
await delay(200);
const n = notifications.find((n) => n.id === params.id);
if (n) n.read = true;
return HttpResponse.json({ code: 0, data: null, message: "OK" });
}),
http.put("/api/notifications/read-all", async () => {
await delay(300);
notifications.forEach((n) => (n.read = true));
return HttpResponse.json({ code: 0, data: null, message: "OK" });
}),
http.delete("/api/notifications/:id", async ({ params }) => {
await delay(200);
notifications = notifications.filter((n) => n.id !== params.id);
return HttpResponse.json({ code: 0, data: null, message: "OK" });
}),
];
+30 -17
View File
@@ -1,19 +1,28 @@
import { http } from "msw";
import { MOCK_USERS } from "../data";
import { createUser, deleteUser, listUsers, updateUser } from "../data";
import { filterUsers, paginateList, parsePaginationParams } from "../utils";
import { withDelay, successResponse, errorResponse, ERROR_CODES } from "../createHandler";
import { findUserById, parseUserIdFromAccessToken } from "../data";
let users = [...MOCK_USERS];
function hasPoint(authHeader: string | null, point: string): boolean {
const userId = parseUserIdFromAccessToken(authHeader);
const user = userId ? findUserById(userId) : undefined;
if (!user) return false;
return user.permissions.includes("*") || user.permissions.includes(point);
}
export const userHandlers = [
http.get("/api/users", async ({ request }) => {
await withDelay(200);
if (!hasPoint(request.headers.get("authorization"), "user:view")) {
return errorResponse(ERROR_CODES.FORBIDDEN, "Forbidden");
}
const url = new URL(request.url);
const { limit, offset } = parsePaginationParams(url.searchParams);
const keyword = url.searchParams.get("keyword") ?? "";
const role = url.searchParams.get("role") ?? "";
const filtered = filterUsers(users, { keyword, role });
const filtered = filterUsers(listUsers(), { keyword, role });
const list = paginateList(filtered, limit, offset);
return successResponse({ list, total: filtered.length });
@@ -21,33 +30,37 @@ export const userHandlers = [
http.post("/api/users", async ({ request }) => {
await withDelay(200);
if (!hasPoint(request.headers.get("authorization"), "user:create")) {
return errorResponse(ERROR_CODES.FORBIDDEN, "Forbidden");
}
const body = (await request.json()) as Record<string, unknown>;
const newUser = {
id: String(users.length + 1),
username: String(body.username),
avatar: null,
const newUser = createUser({
username: String(body.username ?? ""),
email: typeof body.email === "string" ? body.email : null,
roles: (body.roles as string[]) ?? [],
permissions: [],
};
users.push(newUser);
mobile: typeof body.mobile === "string" ? body.mobile : null,
});
return successResponse(newUser);
}),
http.put("/api/users/:id", async ({ params, request }) => {
await withDelay(200);
if (!hasPoint(request.headers.get("authorization"), "user:edit")) {
return errorResponse(ERROR_CODES.FORBIDDEN, "Forbidden");
}
const body = (await request.json()) as Record<string, unknown>;
const idx = users.findIndex((u) => u.id === params.id);
if (idx === -1) {
const updated = updateUser(String(params.id), body);
if (!updated) {
return errorResponse(ERROR_CODES.NOT_FOUND, "User not found");
}
users[idx] = { ...users[idx], ...body };
return successResponse(users[idx]);
return successResponse(updated);
}),
http.delete("/api/users/:id", async ({ params }) => {
http.delete("/api/users/:id", async ({ params, request }) => {
await withDelay(200);
users = users.filter((u) => u.id !== params.id);
if (!hasPoint(request.headers.get("authorization"), "user:delete")) {
return errorResponse(ERROR_CODES.FORBIDDEN, "Forbidden");
}
deleteUser(String(params.id));
return successResponse(null);
}),
];
+182 -2
View File
@@ -11,11 +11,19 @@
import { Route as rootRouteImport } from './routes/__root'
import { Route as AuthRouteImport } from './routes/_auth'
import { Route as IndexRouteImport } from './routes/index'
import { Route as RegisterIndexRouteImport } from './routes/register/index'
import { Route as LoginIndexRouteImport } from './routes/login/index'
import { Route as R404IndexRouteImport } from './routes/404/index'
import { Route as AuthUsersIndexRouteImport } from './routes/_auth/users/index'
import { Route as AuthProfileIndexRouteImport } from './routes/_auth/profile/index'
import { Route as AuthNotificationsIndexRouteImport } from './routes/_auth/notifications/index'
import { Route as AuthMenusIndexRouteImport } from './routes/_auth/menus/index'
import { Route as AuthLogsIndexRouteImport } from './routes/_auth/logs/index'
import { Route as AuthDashboardIndexRouteImport } from './routes/_auth/dashboard/index'
import { Route as AuthAiSettingsIndexRouteImport } from './routes/_auth/ai-settings/index'
import { Route as AuthAdminsIndexRouteImport } from './routes/_auth/admins/index'
import { Route as Auth403IndexRouteImport } from './routes/_auth/403/index'
import { Route as AuthAdminsIdRouteImport } from './routes/_auth/admins/$id'
const AuthRoute = AuthRouteImport.update({
id: '/_auth',
@@ -26,6 +34,11 @@ const IndexRoute = IndexRouteImport.update({
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
const RegisterIndexRoute = RegisterIndexRouteImport.update({
id: '/register/',
path: '/register/',
getParentRoute: () => rootRouteImport,
} as any)
const LoginIndexRoute = LoginIndexRouteImport.update({
id: '/login/',
path: '/login/',
@@ -41,31 +54,82 @@ const AuthUsersIndexRoute = AuthUsersIndexRouteImport.update({
path: '/users/',
getParentRoute: () => AuthRoute,
} as any)
const AuthProfileIndexRoute = AuthProfileIndexRouteImport.update({
id: '/profile/',
path: '/profile/',
getParentRoute: () => AuthRoute,
} as any)
const AuthNotificationsIndexRoute = AuthNotificationsIndexRouteImport.update({
id: '/notifications/',
path: '/notifications/',
getParentRoute: () => AuthRoute,
} as any)
const AuthMenusIndexRoute = AuthMenusIndexRouteImport.update({
id: '/menus/',
path: '/menus/',
getParentRoute: () => AuthRoute,
} as any)
const AuthLogsIndexRoute = AuthLogsIndexRouteImport.update({
id: '/logs/',
path: '/logs/',
getParentRoute: () => AuthRoute,
} as any)
const AuthDashboardIndexRoute = AuthDashboardIndexRouteImport.update({
id: '/dashboard/',
path: '/dashboard/',
getParentRoute: () => AuthRoute,
} as any)
const AuthAiSettingsIndexRoute = AuthAiSettingsIndexRouteImport.update({
id: '/ai-settings/',
path: '/ai-settings/',
getParentRoute: () => AuthRoute,
} as any)
const AuthAdminsIndexRoute = AuthAdminsIndexRouteImport.update({
id: '/admins/',
path: '/admins/',
getParentRoute: () => AuthRoute,
} as any)
const Auth403IndexRoute = Auth403IndexRouteImport.update({
id: '/403/',
path: '/403/',
getParentRoute: () => AuthRoute,
} as any)
const AuthAdminsIdRoute = AuthAdminsIdRouteImport.update({
id: '/admins/$id',
path: '/admins/$id',
getParentRoute: () => AuthRoute,
} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/404/': typeof R404IndexRoute
'/login/': typeof LoginIndexRoute
'/register/': typeof RegisterIndexRoute
'/admins/$id': typeof AuthAdminsIdRoute
'/403/': typeof Auth403IndexRoute
'/admins/': typeof AuthAdminsIndexRoute
'/ai-settings/': typeof AuthAiSettingsIndexRoute
'/dashboard/': typeof AuthDashboardIndexRoute
'/logs/': typeof AuthLogsIndexRoute
'/menus/': typeof AuthMenusIndexRoute
'/notifications/': typeof AuthNotificationsIndexRoute
'/profile/': typeof AuthProfileIndexRoute
'/users/': typeof AuthUsersIndexRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/404': typeof R404IndexRoute
'/login': typeof LoginIndexRoute
'/register': typeof RegisterIndexRoute
'/admins/$id': typeof AuthAdminsIdRoute
'/403': typeof Auth403IndexRoute
'/admins': typeof AuthAdminsIndexRoute
'/ai-settings': typeof AuthAiSettingsIndexRoute
'/dashboard': typeof AuthDashboardIndexRoute
'/logs': typeof AuthLogsIndexRoute
'/menus': typeof AuthMenusIndexRoute
'/notifications': typeof AuthNotificationsIndexRoute
'/profile': typeof AuthProfileIndexRoute
'/users': typeof AuthUsersIndexRoute
}
export interface FileRoutesById {
@@ -74,23 +138,67 @@ export interface FileRoutesById {
'/_auth': typeof AuthRouteWithChildren
'/404/': typeof R404IndexRoute
'/login/': typeof LoginIndexRoute
'/register/': typeof RegisterIndexRoute
'/_auth/admins/$id': typeof AuthAdminsIdRoute
'/_auth/403/': typeof Auth403IndexRoute
'/_auth/admins/': typeof AuthAdminsIndexRoute
'/_auth/ai-settings/': typeof AuthAiSettingsIndexRoute
'/_auth/dashboard/': typeof AuthDashboardIndexRoute
'/_auth/logs/': typeof AuthLogsIndexRoute
'/_auth/menus/': typeof AuthMenusIndexRoute
'/_auth/notifications/': typeof AuthNotificationsIndexRoute
'/_auth/profile/': typeof AuthProfileIndexRoute
'/_auth/users/': typeof AuthUsersIndexRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/404/' | '/login/' | '/403/' | '/dashboard/' | '/users/'
fullPaths:
| '/'
| '/404/'
| '/login/'
| '/register/'
| '/admins/$id'
| '/403/'
| '/admins/'
| '/ai-settings/'
| '/dashboard/'
| '/logs/'
| '/menus/'
| '/notifications/'
| '/profile/'
| '/users/'
fileRoutesByTo: FileRoutesByTo
to: '/' | '/404' | '/login' | '/403' | '/dashboard' | '/users'
to:
| '/'
| '/404'
| '/login'
| '/register'
| '/admins/$id'
| '/403'
| '/admins'
| '/ai-settings'
| '/dashboard'
| '/logs'
| '/menus'
| '/notifications'
| '/profile'
| '/users'
id:
| '__root__'
| '/'
| '/_auth'
| '/404/'
| '/login/'
| '/register/'
| '/_auth/admins/$id'
| '/_auth/403/'
| '/_auth/admins/'
| '/_auth/ai-settings/'
| '/_auth/dashboard/'
| '/_auth/logs/'
| '/_auth/menus/'
| '/_auth/notifications/'
| '/_auth/profile/'
| '/_auth/users/'
fileRoutesById: FileRoutesById
}
@@ -99,6 +207,7 @@ export interface RootRouteChildren {
AuthRoute: typeof AuthRouteWithChildren
R404IndexRoute: typeof R404IndexRoute
LoginIndexRoute: typeof LoginIndexRoute
RegisterIndexRoute: typeof RegisterIndexRoute
}
declare module '@tanstack/react-router' {
@@ -117,6 +226,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
'/register/': {
id: '/register/'
path: '/register'
fullPath: '/register/'
preLoaderRoute: typeof RegisterIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/login/': {
id: '/login/'
path: '/login'
@@ -138,6 +254,34 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthUsersIndexRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/profile/': {
id: '/_auth/profile/'
path: '/profile'
fullPath: '/profile/'
preLoaderRoute: typeof AuthProfileIndexRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/notifications/': {
id: '/_auth/notifications/'
path: '/notifications'
fullPath: '/notifications/'
preLoaderRoute: typeof AuthNotificationsIndexRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/menus/': {
id: '/_auth/menus/'
path: '/menus'
fullPath: '/menus/'
preLoaderRoute: typeof AuthMenusIndexRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/logs/': {
id: '/_auth/logs/'
path: '/logs'
fullPath: '/logs/'
preLoaderRoute: typeof AuthLogsIndexRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/dashboard/': {
id: '/_auth/dashboard/'
path: '/dashboard'
@@ -145,6 +289,20 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthDashboardIndexRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/ai-settings/': {
id: '/_auth/ai-settings/'
path: '/ai-settings'
fullPath: '/ai-settings/'
preLoaderRoute: typeof AuthAiSettingsIndexRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/admins/': {
id: '/_auth/admins/'
path: '/admins'
fullPath: '/admins/'
preLoaderRoute: typeof AuthAdminsIndexRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/403/': {
id: '/_auth/403/'
path: '/403'
@@ -152,18 +310,39 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof Auth403IndexRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/admins/$id': {
id: '/_auth/admins/$id'
path: '/admins/$id'
fullPath: '/admins/$id'
preLoaderRoute: typeof AuthAdminsIdRouteImport
parentRoute: typeof AuthRoute
}
}
}
interface AuthRouteChildren {
AuthAdminsIdRoute: typeof AuthAdminsIdRoute
Auth403IndexRoute: typeof Auth403IndexRoute
AuthAdminsIndexRoute: typeof AuthAdminsIndexRoute
AuthAiSettingsIndexRoute: typeof AuthAiSettingsIndexRoute
AuthDashboardIndexRoute: typeof AuthDashboardIndexRoute
AuthLogsIndexRoute: typeof AuthLogsIndexRoute
AuthMenusIndexRoute: typeof AuthMenusIndexRoute
AuthNotificationsIndexRoute: typeof AuthNotificationsIndexRoute
AuthProfileIndexRoute: typeof AuthProfileIndexRoute
AuthUsersIndexRoute: typeof AuthUsersIndexRoute
}
const AuthRouteChildren: AuthRouteChildren = {
AuthAdminsIdRoute: AuthAdminsIdRoute,
Auth403IndexRoute: Auth403IndexRoute,
AuthAdminsIndexRoute: AuthAdminsIndexRoute,
AuthAiSettingsIndexRoute: AuthAiSettingsIndexRoute,
AuthDashboardIndexRoute: AuthDashboardIndexRoute,
AuthLogsIndexRoute: AuthLogsIndexRoute,
AuthMenusIndexRoute: AuthMenusIndexRoute,
AuthNotificationsIndexRoute: AuthNotificationsIndexRoute,
AuthProfileIndexRoute: AuthProfileIndexRoute,
AuthUsersIndexRoute: AuthUsersIndexRoute,
}
@@ -174,6 +353,7 @@ const rootRouteChildren: RootRouteChildren = {
AuthRoute: AuthRouteWithChildren,
R404IndexRoute: R404IndexRoute,
LoginIndexRoute: LoginIndexRoute,
RegisterIndexRoute: RegisterIndexRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
+4 -1
View File
@@ -6,6 +6,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useSettingsStore } from "@/stores/settings";
import { useAppTheme } from "@/hooks/useAppTheme";
import { NotFound } from "@/components/NotFound";
import { ErrorBoundary } from "@/components/ErrorBoundary";
import "@/index.css";
const queryClient = new QueryClient({
@@ -30,7 +31,9 @@ function RootComponent() {
<QueryClientProvider client={queryClient}>
<ConfigProvider {...configProviderProps} locale={enUS}>
<App>
<Outlet />
<ErrorBoundary>
<Outlet />
</ErrorBoundary>
</App>
</ConfigProvider>
</QueryClientProvider>
+108
View File
@@ -0,0 +1,108 @@
import { createFileRoute } from "@tanstack/react-router";
import { useQuery } from "@tanstack/react-query";
import { App, Avatar, Button, Card, Col, Descriptions, Flex, Row, Space, Spin, Tag, theme } from "antd";
import { httpClient } from "@/utils/http";
import { ADMIN_ENDPOINTS } from "@/api/admin";
import { UserSchema } from "@/api/schemas";
export const Route = createFileRoute("/_auth/admins/$id")({
component: AdminDetailPage,
});
function AdminDetailPage() {
const { id } = Route.useParams();
const { message } = App.useApp();
const { token } = theme.useToken();
const adminQuery = useQuery({
queryKey: ["admin-detail", id],
queryFn: async () => {
const raw = await httpClient.get(ADMIN_ENDPOINTS.detail(id));
return UserSchema.parse(raw);
},
});
if (adminQuery.isLoading) {
return (
<Flex align="center" justify="center" style={{ minHeight: 240 }}>
<Spin size="large" />
</Flex>
);
}
if (adminQuery.isError || !adminQuery.data) {
return <Card>Admin not found.</Card>;
}
const admin = adminQuery.data;
return (
<Card>
<Row gutter={[24, 24]} align="middle" style={{ marginBottom: token.marginLG }}>
<Col>
<Avatar src={admin.avatar ?? undefined} size={96}>
{admin.username?.[0]?.toUpperCase()}
</Avatar>
</Col>
<Col flex="auto">
<Flex vertical gap={token.marginXS}>
<div>
<strong>Username:</strong> {admin.username}
</div>
<div>
<strong>Real Name:</strong> {admin.realName || "-"}
</div>
<div>
<strong>Nick Name:</strong> {admin.nickName || "-"}
</div>
<Space wrap>
<Tag color={admin.status === 1 ? "success" : "default"}>
{admin.status === 1 ? "Enabled" : "Disabled"}
</Tag>
<Tag>{admin.role || "No title"}</Tag>
{admin.department ? <Tag color="processing">{admin.department}</Tag> : null}
</Space>
<Space style={{ marginTop: token.marginSM }}>
<Button type="primary" onClick={() => message.info("Password reset simulated")}>Reset Password</Button>
<Button
onClick={() => message.info(`${admin.status === 1 ? "Disable" : "Enable"} action simulated`)}
>
{admin.status === 1 ? "Disable Account" : "Enable Account"}
</Button>
</Space>
</Flex>
</Col>
<Col style={{ width: 260 }}>
<div>
<strong>Last Login:</strong>
</div>
<div style={{ color: token.colorTextSecondary }}>{admin.lastLogin || "-"}</div>
<div style={{ marginTop: token.marginSM }}>
<strong>Created By:</strong> {admin.createdBy || "-"}
</div>
</Col>
</Row>
<Descriptions column={1} bordered>
<Descriptions.Item label="ID">{admin.id}</Descriptions.Item>
<Descriptions.Item label="Email">{admin.email || "-"}</Descriptions.Item>
<Descriptions.Item label="Mobile">{admin.mobile || "-"}</Descriptions.Item>
<Descriptions.Item label="Remark">{admin.remark || "-"}</Descriptions.Item>
</Descriptions>
<Card type="inner" title="Permission List" style={{ marginTop: token.marginLG }}>
{admin.permissions.length ? (
<Space size={[8, 8]} wrap>
{admin.permissions.map((permission) => (
<Tag key={permission} color="blue">
{permission}
</Tag>
))}
</Space>
) : (
<div style={{ color: token.colorTextSecondary }}>No permissions</div>
)}
</Card>
</Card>
);
}
@@ -0,0 +1,104 @@
import { Form, Input, Select, Switch } from "antd";
import type { FormInstance } from "antd/es/form";
import type { CreateAdminRequest, Admin } from "@/api/admin";
import { BaseFormModal } from "@/components/FormModal";
import { APP_PERMISSION_OPTIONS } from "@/utils/appMenu";
export type AdminFormValues = Omit<CreateAdminRequest, "status"> & {
password?: string;
status?: boolean;
};
export type FormModalProps = {
open: boolean;
editingAdmin: Admin | null;
form: FormInstance<AdminFormValues>;
confirmLoading: boolean;
onCancel: () => void;
onFinish: (values: AdminFormValues) => void;
};
export function FormModal({
open,
editingAdmin,
form,
confirmLoading,
onCancel,
onFinish,
}: FormModalProps) {
return (
<BaseFormModal<AdminFormValues>
open={open}
title={editingAdmin ? "Edit Admin" : "New Admin"}
okText="Save"
cancelText="Cancel"
form={form}
confirmLoading={confirmLoading}
onCancel={onCancel}
onFinish={onFinish}
>
<Form.Item
name="username"
label="Username"
rules={[{ required: true, message: "Please enter username" }]}
>
<Input />
</Form.Item>
<Form.Item
name="password"
label="Password"
rules={
editingAdmin
? [{ min: 6, message: "Password must be at least 6 characters" }]
: [
{ required: true, message: "Please enter password" },
{ min: 6, message: "Password must be at least 6 characters" },
]
}
extra={editingAdmin ? "Leave blank to keep the current password" : undefined}
>
<Input.Password />
</Form.Item>
<Form.Item name="realName" label="Real Name">
<Input />
</Form.Item>
<Form.Item name="nickName" label="Nick Name">
<Input />
</Form.Item>
<Form.Item name="email" label="Email" rules={[{ type: "email", message: "Invalid email" }]}>
<Input />
</Form.Item>
<Form.Item name="mobile" label="Mobile">
<Input />
</Form.Item>
<Form.Item name="department" label="Department">
<Input />
</Form.Item>
<Form.Item name="role" label="Title">
<Input />
</Form.Item>
<Form.Item
name="roles"
label="Roles"
rules={[{ required: true, message: "Please select roles" }]}
>
<Select
mode="multiple"
options={[
{ label: "Admin", value: "admin" },
{ label: "Editor", value: "editor" },
]}
/>
</Form.Item>
<Form.Item name="permissions" label="Permissions">
<Select mode="multiple" options={[...APP_PERMISSION_OPTIONS]} />
</Form.Item>
<Form.Item name="remark" label="Remark">
<Input.TextArea rows={3} />
</Form.Item>
<Form.Item name="status" label="Enabled" valuePropName="checked" initialValue={true}>
<Switch checkedChildren="Enabled" unCheckedChildren="Disabled" />
</Form.Item>
</BaseFormModal>
);
}
@@ -0,0 +1,91 @@
import { Button, Input, Select, theme } from "antd";
import { Plus, ShieldCheck } from "lucide-react";
import { forwardRef, useMemo } from "react";
import { FilterToolbar } from "@/components/FilterToolbar";
const FILTER_CONTROL_WIDTH = 220;
export type ToolbarProps = {
keywordInput: string;
onKeywordChange: (value: string) => void;
onSearch: (keyword: string) => void;
onClearSearch: () => void;
roleValue: string | undefined;
onRoleChange: (role: string) => void;
canCreate: boolean;
onCreateClick: () => void;
};
export const Toolbar = forwardRef<HTMLDivElement, ToolbarProps>(function Toolbar(
{
keywordInput,
onKeywordChange,
onSearch,
onClearSearch,
roleValue,
onRoleChange,
canCreate,
onCreateClick,
},
ref,
) {
const { token } = theme.useToken();
const slots = useMemo(
() => [
{
key: "keyword",
minWidth: FILTER_CONTROL_WIDTH,
children: (
<Input.Search
allowClear
placeholder="Search admin"
style={{ width: FILTER_CONTROL_WIDTH }}
value={keywordInput}
onChange={(e) => onKeywordChange(e.target.value)}
onSearch={(v) => onSearch(v)}
onClear={onClearSearch}
/>
),
},
{
key: "role",
minWidth: FILTER_CONTROL_WIDTH,
children: (
<Select
allowClear
placeholder="Role"
style={{ width: FILTER_CONTROL_WIDTH }}
prefix={<ShieldCheck size={token.fontSize} />}
value={roleValue}
onChange={(v) => onRoleChange(v ?? "")}
options={[
{ label: "Admin", value: "admin" },
{ label: "Editor", value: "editor" },
]}
/>
),
},
],
[keywordInput, onClearSearch, onKeywordChange, onRoleChange, onSearch, roleValue, token.fontSize],
);
return (
<FilterToolbar
ref={ref}
slots={slots}
actions={
<Button
type="primary"
icon={<Plus size={token.fontSize} />}
onClick={onCreateClick}
disabled={!canCreate}
>
Create Admin
</Button>
}
moreFiltersLabel="More filters"
moreFiltersTitle="More filters"
/>
);
});
@@ -0,0 +1,364 @@
import { Link, createFileRoute, useNavigate } from "@tanstack/react-router";
import { Avatar, Button, Card, Dropdown, Flex, Form, Space, Tag, App, theme } from "antd";
import type { TablePaginationConfig } from "antd/es/table/interface";
import { useEffect, useMemo, useState } from "react";
import { z } from "zod/v4";
import { MoreVertical, Pencil, Trash2 } from "lucide-react";
import { DataTable } from "@/components/DataTable";
import { useResourceCRUD } from "@/hooks/useResourceCRUD";
import { ADMIN_ENDPOINTS } from "@/api/admin";
import type { Admin, CreateAdminRequest, UpdateAdminRequest } from "@/api/admin";
import {
CreateAdminRequestSchema,
PaginatedResponseSchema,
UpdateAdminRequestSchema,
UserSchema,
} from "@/api/schemas";
import { httpClient } from "@/utils/http";
import { useAuthStore } from "@/stores/auth";
import { FormModal, type AdminFormValues } from "./-FormModal";
import { Toolbar } from "./-Toolbar";
function getErrorMessage(error: unknown, fallback: string): string {
if (error instanceof Error && error.message.trim()) return error.message;
return fallback;
}
const AdminSearchParamsSchema = z.object({
limit: z.number().int().positive().catch(20),
offset: z.number().int().nonnegative().catch(0),
sortField: z.string().nullable().catch(null),
sortOrder: z.enum(["ascend", "descend"]).nullable().catch(null),
keyword: z.string().catch(""),
role: z.string().catch("")
});
export const Route = createFileRoute("/_auth/admins/")({
validateSearch: (search) => AdminSearchParamsSchema.parse(search),
component: AdminsPage,
});
const paginatedAdminSchema = PaginatedResponseSchema(UserSchema);
function AdminsPage() {
const search = Route.useSearch();
const navigate = useNavigate({ from: Route.fullPath });
const { message, modal } = App.useApp();
const { token } = theme.useToken();
const [modalOpen, setModalOpen] = useState(false);
const [editingAdmin, setEditingAdmin] = useState<Admin | null>(null);
const [form] = Form.useForm<AdminFormValues>();
const hasPermission = useAuthStore((s) => s.hasPermission);
const canCreate = hasPermission("admin:create");
const canEdit = hasPermission("admin:edit");
const canDelete = hasPermission("admin:delete");
const [keywordInput, setKeywordInput] = useState(search.keyword);
const currentPage = Math.floor(search.offset / search.limit) + 1;
useEffect(() => {
setKeywordInput(search.keyword);
}, [search.keyword]);
const { data, isLoading, createMutation, updateMutation, deleteMutation } = useResourceCRUD<
{ list: Admin[]; total: number },
CreateAdminRequest,
UpdateAdminRequest & { id: string }
>({
queryKey: [
"admins",
search.limit,
search.offset,
search.keyword,
search.role,
search.sortField,
search.sortOrder,
],
invalidateKey: ["admins"],
queryFn: () =>
httpClient.get(ADMIN_ENDPOINTS.list, {
params: {
limit: search.limit,
offset: search.offset,
keyword: search.keyword || undefined,
role: search.role || undefined,
},
}),
select: (raw) => paginatedAdminSchema.shape.data.parse(raw),
createFn: (values) => httpClient.post(ADMIN_ENDPOINTS.create, CreateAdminRequestSchema.parse(values)),
updateFn: ({ id, ...values }) =>
httpClient.put(ADMIN_ENDPOINTS.update(id), UpdateAdminRequestSchema.parse(values)),
deleteFn: (id) => httpClient.delete(ADMIN_ENDPOINTS.delete(id)),
createLifecycle: {
onSuccess: () => {
message.success("Admin created");
setModalOpen(false);
form.resetFields();
},
onError: (_values, error) => message.error(getErrorMessage(error, "Create admin failed")),
},
updateLifecycle: {
onSuccess: () => {
message.success("Admin updated");
setModalOpen(false);
setEditingAdmin(null);
form.resetFields();
},
onError: (_values, error) => message.error(getErrorMessage(error, "Update admin failed")),
},
deleteLifecycle: {
onSuccess: () => message.success("Admin deleted"),
onError: (_id, error) => message.error(getErrorMessage(error, "Delete admin failed")),
},
});
const confirmDelete = (record: Admin) => {
modal.confirm({
title: "Delete admin?",
content: `This will remove ${record.username}.`,
okText: "Delete",
okType: "danger",
cancelText: "Cancel",
onOk: () => deleteMutation.mutate(record.id),
});
};
const columns = useMemo(
() => [
{
title: "Admin",
dataIndex: "username",
key: "username",
width: 320,
render: (_: unknown, record: Admin) => (
<Flex align="center" gap={token.marginSM} style={{ minWidth: 0 }}>
<Avatar size={28} src={record.avatar ?? undefined}>
{record.username?.[0]?.toUpperCase()}
</Avatar>
<Flex vertical style={{ minWidth: 0 }}>
<Link
to="/admins/$id"
params={{ id: record.id }}
style={{ whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}
>
{record.username}
</Link>
<span style={{ color: token.colorTextSecondary, fontSize: token.fontSizeSM }}>
{record.realName || record.email || "No profile info"}
</span>
</Flex>
</Flex>
),
},
{
title: "Role",
dataIndex: "role",
key: "role",
width: 240,
render: (_: unknown, record: Admin) => (
<Space wrap>
{(record.roles ?? []).map((role) => (
<Tag key={role}>{role}</Tag>
))}
</Space>
),
},
{
title: "Permissions",
dataIndex: "permissions",
key: "permissions",
width: 720,
render: (_: unknown, record: Admin) => {
const permissions = record.permissions ?? [];
const isAdmin = (record.roles ?? []).includes("admin");
if (isAdmin || permissions.includes("*")) {
return <Tag color="gold">*</Tag>;
}
return (
<Space size={[4, 4]} wrap>
{permissions.length ? permissions.map((point) => <Tag key={point}>{point}</Tag>) : <Tag>none</Tag>}
</Space>
);
},
},
{
title: "Status",
dataIndex: "status",
key: "status",
width: 150,
render: (status: number | undefined) => (
<Tag color={status === 1 ? "success" : "default"}>{status === 1 ? "Enabled" : "Disabled"}</Tag>
),
},
{
title: "Actions",
key: "actions",
width: 120,
align: "right" as const,
render: (_: unknown, record: Admin) => {
const items = [
canEdit
? {
key: "edit",
icon: <Pencil size={token.fontSize} />,
label: "Edit",
onClick: () => {
setEditingAdmin(record);
form.setFieldsValue({
...record,
password: "",
status: record.status === 1,
} as AdminFormValues);
setModalOpen(true);
},
}
: null,
canDelete
? {
key: "delete",
icon: <Trash2 size={token.fontSize} />,
label: "Delete",
danger: true,
onClick: () => confirmDelete(record),
}
: null,
].filter((item): item is NonNullable<typeof item> => item != null);
if (!items.length) {
return <span style={{ color: token.colorTextSecondary }}>—</span>;
}
return (
<Dropdown menu={{ items }} placement="bottomRight">
<Button type="text" icon={<MoreVertical size={token.fontSize} />} />
</Dropdown>
);
},
},
],
[
canDelete,
canEdit,
form,
modal,
token.colorTextSecondary,
token.fontSize,
token.fontSizeSM,
token.marginSM,
],
);
const applySearch = (keyword: string) => {
void navigate({
search: {
...search,
keyword: keyword.trim(),
offset: 0,
},
});
};
const handleSubmit = (values: AdminFormValues) => {
if (editingAdmin && !canEdit) {
message.error("No permission to edit admins");
return;
}
if (!editingAdmin && !canCreate) {
message.error("No permission to create admins");
return;
}
const payload = {
...values,
email: values.email || null,
realName: values.realName || null,
nickName: values.nickName || null,
mobile: values.mobile || null,
department: values.department || null,
role: values.role || null,
remark: values.remark || null,
status: values.status ? 1 : 0,
roles: values.roles?.length ? values.roles : ["editor"],
permissions: values.permissions ?? [],
};
if (editingAdmin) {
const updatePayload = {
...payload,
password: payload.password?.trim() ? payload.password : undefined,
};
updateMutation.mutate({ id: editingAdmin.id, ...updatePayload });
return;
}
createMutation.mutate(payload as CreateAdminRequest);
};
const pagination: TablePaginationConfig = {
current: currentPage,
pageSize: search.limit,
total: data?.total ?? 0,
showSizeChanger: true,
onChange: (page, pageSize) => {
void navigate({
search: {
...search,
limit: pageSize,
offset: (page - 1) * pageSize,
},
});
},
};
return (
<Card style={{ flex: 1, minHeight: 0, display: "flex", flexDirection: "column" }}>
<Flex vertical gap={token.margin} style={{ flex: 1, minHeight: 0 }}>
<Toolbar
keywordInput={keywordInput}
onKeywordChange={setKeywordInput}
onSearch={applySearch}
onClearSearch={() => applySearch("")}
roleValue={search.role || undefined}
onRoleChange={(role) => {
void navigate({
search: {
...search,
role,
offset: 0,
},
});
}}
canCreate={canCreate}
onCreateClick={() => {
if (!canCreate) return;
setEditingAdmin(null);
form.resetFields();
form.setFieldsValue({ roles: ["admin"], permissions: ["admin:view"], status: true });
setModalOpen(true);
}}
/>
<DataTable<Admin>
dataSource={data?.list ?? []}
columns={columns}
rowKey="id"
pagination={pagination}
loading={isLoading}
size="small"
scroll={{ x: "max-content" }}
/>
</Flex>
<FormModal
open={modalOpen}
editingAdmin={editingAdmin}
form={form}
confirmLoading={createMutation.isPending || updateMutation.isPending}
onCancel={() => {
setModalOpen(false);
setEditingAdmin(null);
form.resetFields();
}}
onFinish={handleSubmit}
/>
</Card>
);
}
@@ -0,0 +1,84 @@
import { createFileRoute } from "@tanstack/react-router";
import { App, Button, Card, Flex, Form, Input, InputNumber, Select, Switch, Typography, theme } from "antd";
import { useMemo } from "react";
import { useAIStore } from "@/stores/ai";
const { Text } = Typography;
type AIFormValues = {
enabled: boolean;
provider: "openai" | "azure-openai" | "anthropic";
model: string;
systemPrompt: string;
temperature: number;
maxTokens: number;
};
export const Route = createFileRoute("/_auth/ai-settings/")({
component: AISettingsPage,
});
function AISettingsPage() {
const { message } = App.useApp();
const { token } = theme.useToken();
const [form] = Form.useForm<AIFormValues>();
const { enabled, provider, model, systemPrompt, temperature, maxTokens, setConfig } = useAIStore();
const initialValues = useMemo(
() => ({ enabled, provider, model, systemPrompt, temperature, maxTokens }),
[enabled, provider, model, systemPrompt, temperature, maxTokens],
);
return (
<Flex vertical gap={token.marginLG} style={{ flex: 1, minHeight: 0 }}>
<Card title="AI Configuration">
<Form
form={form}
layout="vertical"
initialValues={initialValues}
onFinish={(values) => {
setConfig(values);
message.success("AI settings saved");
}}
>
<Form.Item label="Enable AI" name="enabled" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item label="Provider" name="provider" rules={[{ required: true }]}>
<Select
options={[
{ label: "OpenAI", value: "openai" },
{ label: "Azure OpenAI", value: "azure-openai" },
{ label: "Anthropic", value: "anthropic" },
]}
/>
</Form.Item>
<Form.Item label="Model" name="model" rules={[{ required: true, message: "Model is required" }]}>
<Input placeholder="gpt-5.3-codex" />
</Form.Item>
<Form.Item
label="System Prompt"
name="systemPrompt"
rules={[{ required: true, message: "System prompt is required" }]}
>
<Input.TextArea rows={4} />
</Form.Item>
<Form.Item label="Temperature" name="temperature">
<InputNumber min={0} max={1} step={0.1} style={{ width: 200 }} />
</Form.Item>
<Form.Item label="Max Tokens" name="maxTokens">
<InputNumber min={256} max={8192} step={128} style={{ width: 200 }} />
</Form.Item>
<Text type="secondary" style={{ display: "block", marginBottom: token.margin }}>
This page configures global AI behavior for the app shell and AI assistants.
</Text>
<Button type="primary" htmlType="submit">
Save AI Settings
</Button>
</Form>
</Card>
</Flex>
);
}
@@ -20,12 +20,12 @@
background-color: var(--dash-recent-hover-bg);
}
.dash-chart-placeholder {
transition: border-color 0.2s ease;
.dash-timeline {
padding-top: 4px;
}
.dash-chart-placeholder:hover {
border-color: var(--dash-chart-hover-border);
.dash-timeline .ant-timeline-item-content {
min-height: 52px;
}
.dash-skel-stat .ant-skeleton-input {
+54 -20
View File
@@ -1,7 +1,7 @@
import type { CSSProperties } from "react";
import { useMemo } from "react";
import { createFileRoute } from "@tanstack/react-router";
import { Card, Col, Row, Typography, Avatar, theme, Flex, Skeleton } from "antd";
import { Card, Col, Row, Typography, Avatar, theme, Flex, Skeleton, Timeline, Tag } from "antd";
import { useQuery } from "@tanstack/react-query";
import { DollarSign, Users, CreditCard, Activity } from "lucide-react";
import "./index.css";
@@ -194,6 +194,48 @@ function DashboardPage() {
[],
);
const timelineItems = useMemo(
() => [
{
color: "green",
content: (
<Flex vertical gap={4}>
<Text strong>08:30 Deploy V3.2.0</Text>
<Text type="secondary">Release branch merged and production rollout completed.</Text>
</Flex>
),
},
{
color: "blue",
content: (
<Flex vertical gap={4}>
<Text strong>10:10 Menu policy updated</Text>
<Text type="secondary">Admin changed sidebar visibility and permission mapping.</Text>
</Flex>
),
},
{
color: "gold",
content: (
<Flex vertical gap={4}>
<Text strong>13:20 Security review</Text>
<Text type="secondary">Token refresh behavior and 403 routes validated.</Text>
</Flex>
),
},
{
color: "red",
content: (
<Flex vertical gap={4}>
<Text strong>15:50 Incident recovery</Text>
<Text type="secondary">User creation spike handled and queue restored.</Text>
</Flex>
),
},
],
[],
);
if (isPending) {
return <DashboardSkeleton />;
}
@@ -228,26 +270,18 @@ function DashboardPage() {
<Card
className="dash-card-interactive"
style={{ ...cardHoverStyle, height: "100%" }}
title={<Title level={5} style={{ margin: 0 }}>Overview</Title>}
title={
<Flex align="center" gap={token.marginSM}>
<Title level={5} style={{ margin: 0 }}>
Timeline
</Title>
<Tag variant="filled" color="processing">
Today
</Tag>
</Flex>
}
>
<Flex
className="dash-chart-placeholder"
style={{
height: 300,
width: "100%",
backgroundColor: token.colorBgLayout,
borderRadius: token.borderRadius,
alignItems: "center",
justifyContent: "center",
border: `1px dashed ${token.colorBorder}`,
["--dash-chart-hover-bg" as string]: token.colorBgLayout,
["--dash-chart-hover-border" as string]: token.colorTextTertiary,
}}
>
<Text type="secondary" style={{ fontSize: token.fontSizeSM }}>
Chart Placeholder
</Text>
</Flex>
<Timeline className="dash-timeline" items={timelineItems} />
</Card>
</Col>
<Col xs={24} lg={10}>
+440
View File
@@ -0,0 +1,440 @@
import { createFileRoute } from "@tanstack/react-router";
import {
App,
Button,
Card,
Divider,
Flex,
Form,
Input,
InputNumber,
Switch,
Tree,
Typography,
theme,
} from "antd";
import type { DataNode } from "antd/es/tree";
import { useMemo, useState } from "react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { httpClient } from "@/utils/http";
import { MENU_ENDPOINTS } from "@/api/menu";
import { MenuItemSchema } from "@/api/schemas";
import type { MenuItem } from "@/api/schemas";
import { fetchSessionAndApplyToStore } from "@/utils/session";
import { FolderPlus, Plus, Trash2 } from "lucide-react";
const { Text } = Typography;
export const Route = createFileRoute("/_auth/menus/")({
component: MenusPage,
});
type MenuFormValues = {
name: string;
path: string;
icon: string;
sort: number;
hidden: boolean;
permissionsCsv: string;
};
type NodeContext = {
node: MenuItem;
parentId: string | null;
index: number;
};
function cloneMenus(menus: MenuItem[]): MenuItem[] {
return JSON.parse(JSON.stringify(menus)) as MenuItem[];
}
function nextNodeId(): string {
return `menu-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
}
function withChildren(item: MenuItem, children: MenuItem[]): MenuItem {
if (item.kind === "group") {
return { ...item, children };
}
return { ...item, children: children.length ? children : null };
}
function normalizeSort(items: MenuItem[]): MenuItem[] {
return items.map((item, index) => {
if (item.kind === "group") {
return { ...item, sort: index, children: normalizeSort(item.children) };
}
if (!item.children?.length) {
return { ...item, sort: index };
}
return { ...item, sort: index, children: normalizeSort(item.children) };
});
}
function buildTreeData(items: MenuItem[]): DataNode[] {
return items
.slice()
.sort((a, b) => a.sort - b.sort)
.map((item) => {
const suffix = item.hidden ? " (hidden)" : "";
const title = `${item.name}${suffix}`;
return {
key: item.id,
title: item.kind === "group" ? `${title}` : `${title} ${item.path}`,
children: item.children?.length ? buildTreeData(item.children) : undefined,
};
});
}
function findNodeContext(items: MenuItem[], id: string, parentId: string | null = null): NodeContext | null {
for (let index = 0; index < items.length; index += 1) {
const item = items[index];
if (item.id === id) return { node: item, parentId, index };
if (item.children?.length) {
const found = findNodeContext(item.children, id, item.id);
if (found) return found;
}
}
return null;
}
function findNode(items: MenuItem[], id: string): MenuItem | null {
return findNodeContext(items, id)?.node ?? null;
}
function updateNode(items: MenuItem[], id: string, patch: Partial<MenuItem>): MenuItem[] {
return items.map((item) => {
if (item.id === id) {
if (item.kind === "group") {
return {
...item,
name: patch.name ?? item.name,
icon: patch.icon ?? item.icon,
permissions: patch.permissions ?? item.permissions,
sort: patch.sort ?? item.sort,
hidden: patch.hidden ?? item.hidden,
};
}
return {
...item,
name: patch.name ?? item.name,
path: patch.path && typeof patch.path === "string" ? patch.path : item.path,
icon: patch.icon ?? item.icon,
permissions: patch.permissions ?? item.permissions,
sort: patch.sort ?? item.sort,
hidden: patch.hidden ?? item.hidden,
};
}
if (!item.children?.length) return item;
return withChildren(item, updateNode(item.children, id, patch));
});
}
function removeNode(items: MenuItem[], id: string): { next: MenuItem[]; removed: MenuItem | null } {
let removed: MenuItem | null = null;
const next: MenuItem[] = [];
for (const item of items) {
if (item.id === id) {
removed = item;
continue;
}
if (item.children?.length) {
const childResult = removeNode(item.children, id);
if (childResult.removed) {
removed = childResult.removed;
next.push(withChildren(item, childResult.next));
continue;
}
}
next.push(item);
}
return { next, removed };
}
function insertChild(items: MenuItem[], parentId: string, child: MenuItem): MenuItem[] {
return items.map((item) => {
if (item.id === parentId) {
const currentChildren = item.children ? [...item.children] : [];
return withChildren(item, normalizeSort([...currentChildren, child]));
}
if (!item.children?.length) return item;
return withChildren(item, insertChild(item.children, parentId, child));
});
}
function replaceSiblingList(
items: MenuItem[],
parentId: string | null,
transform: (siblings: MenuItem[]) => MenuItem[],
): MenuItem[] {
if (parentId == null) {
return normalizeSort(transform(items));
}
return items.map((item) => {
if (item.id === parentId) {
const siblings = item.children ? [...item.children] : [];
return withChildren(item, normalizeSort(transform(siblings)));
}
if (!item.children?.length) return item;
return withChildren(item, replaceSiblingList(item.children, parentId, transform));
});
}
function permissionsToCsv(permissions: string[] | null): string {
return permissions?.join(",") ?? "";
}
function csvToPermissions(csv: string): string[] | null {
const list = csv
.split(",")
.map((s) => s.trim())
.filter(Boolean);
return list.length ? list : null;
}
function MenusPage() {
const { message } = App.useApp();
const { token } = theme.useToken();
const [form] = Form.useForm<MenuFormValues>();
const [selectedId, setSelectedId] = useState<string | null>(null);
const [draftMenus, setDraftMenus] = useState<MenuItem[] | null>(null);
const { data, isLoading } = useQuery({
queryKey: ["menus"],
queryFn: () => httpClient.get(MENU_ENDPOINTS.list),
select: (raw) => MenuItemSchema.array().parse(raw),
});
const menus = draftMenus ?? data ?? [];
const selectedNode = selectedId ? findNode(menus, selectedId) : null;
const selectedContext = selectedId ? findNodeContext(menus, selectedId) : null;
const treeData = useMemo(() => buildTreeData(menus), [menus]);
const saveMutation = useMutation({
mutationFn: (nextMenus: MenuItem[]) => httpClient.put(MENU_ENDPOINTS.update, { menus: nextMenus }),
onSuccess: async () => {
setDraftMenus(null);
await fetchSessionAndApplyToStore();
message.success("Menus saved");
},
onError: (error) => {
const content = error instanceof Error ? error.message : "Save menus failed";
message.error(content);
},
});
const bindForm = (node: MenuItem | null) => {
if (!node) {
form.resetFields();
return;
}
form.setFieldsValue({
name: node.name,
path: node.path ?? "",
icon: node.icon ?? "",
sort: node.sort,
hidden: node.hidden,
permissionsCsv: permissionsToCsv(node.permissions),
});
};
const createDefaultItem = (name: string): MenuItem => ({
id: nextNodeId(),
kind: "item",
name,
path: `/path-${Date.now()}`,
icon: "IconLucideCircleDashed",
permissions: null,
sort: 0,
hidden: false,
children: null,
});
const createDefaultGroup = (): MenuItem => ({
id: nextNodeId(),
kind: "group",
name: "New Group",
path: null,
icon: "IconLucideSettings",
permissions: null,
sort: 0,
hidden: false,
children: [],
});
const canDrop = () => true;
const handleDrop = (info: {
dragNode: DataNode;
node: DataNode;
dropToGap: boolean;
dropPosition: number;
}) => {
const dragId = String(info.dragNode.key);
const targetId = String(info.node.key);
if (dragId === targetId) return;
const removed = removeNode(menus, dragId);
if (!removed.removed) return;
const targetPosStr = String((info.node as { pos?: string }).pos ?? "0");
const rawPosition = Number(targetPosStr.split("-").at(-1) ?? 0);
const relativeDrop = info.dropPosition - rawPosition;
if (!info.dropToGap) {
const withChild = insertChild(removed.next, targetId, removed.removed);
setDraftMenus(normalizeSort(withChild));
return;
}
const targetCtx = findNodeContext(removed.next, targetId);
if (!targetCtx) return;
const insertIndex = relativeDrop < 0 ? targetCtx.index : targetCtx.index + 1;
const withSibling = replaceSiblingList(removed.next, targetCtx.parentId, (siblings) => {
const next = [...siblings];
next.splice(insertIndex, 0, removed.removed as MenuItem);
return next;
});
setDraftMenus(normalizeSort(withSibling));
};
return (
<Flex vertical gap={token.marginLG} style={{ flex: 1, minHeight: 0 }}>
<Card title="Menus Editor" loading={isLoading}>
<Flex gap={token.marginSM} wrap style={{ marginBottom: token.margin }}>
<Button
icon={<FolderPlus size={token.fontSize} />}
onClick={() => {
const next = normalizeSort([...menus, createDefaultGroup()]);
setDraftMenus(next);
}}
>
Add Group
</Button>
<Button
icon={<Plus size={token.fontSize} />}
disabled={!selectedContext}
onClick={() => {
if (!selectedContext) return;
const next = replaceSiblingList(menus, selectedContext.parentId, (siblings) => {
const copy = [...siblings];
copy.splice(selectedContext.index + 1, 0, createDefaultItem("New Item"));
return copy;
});
setDraftMenus(normalizeSort(next));
}}
>
Add Sibling Item
</Button>
<Button
icon={<Plus size={token.fontSize} />}
disabled={!selectedNode}
onClick={() => {
if (!selectedNode) return;
const next = insertChild(menus, selectedNode.id, createDefaultItem("New Child Item"));
setDraftMenus(normalizeSort(next));
}}
>
Add Child Item
</Button>
<Button
danger
icon={<Trash2 size={token.fontSize} />}
disabled={!selectedNode}
onClick={() => {
if (!selectedNode) return;
const removed = removeNode(menus, selectedNode.id);
setDraftMenus(normalizeSort(removed.next));
setSelectedId(null);
form.resetFields();
}}
>
Delete
</Button>
</Flex>
<Divider style={{ marginBlock: token.marginSM }} />
<Flex gap={token.marginLG} align="stretch" style={{ minHeight: 420 }}>
<Card
style={{ flex: "0 0 360px", overflow: "auto" }}
styles={{ body: { padding: token.paddingSM } }}
>
<Tree
draggable
allowDrop={canDrop}
selectedKeys={selectedId ? [selectedId] : []}
treeData={treeData}
onDrop={handleDrop}
onSelect={(keys) => {
const nextId = String(keys[0] ?? "");
setSelectedId(nextId || null);
bindForm(nextId ? findNode(menus, nextId) : null);
}}
/>
</Card>
<Card style={{ flex: 1 }}>
{selectedNode ? (
<Form
form={form}
layout="vertical"
onValuesChange={(_, values) => {
if (!selectedId) return;
const next = updateNode(menus, selectedId, {
name: values.name,
path: selectedNode.kind === "item" ? (values.path || "/") : null,
icon: values.icon || null,
sort: values.sort ?? 0,
hidden: !!values.hidden,
permissions: csvToPermissions(values.permissionsCsv || ""),
});
setDraftMenus(normalizeSort(next));
}}
>
<Form.Item label="Name" name="name" rules={[{ required: true, message: "Name is required" }]}>
<Input />
</Form.Item>
<Form.Item label="Path" name="path" extra="Group nodes should keep path empty">
<Input disabled={selectedNode.kind === "group"} />
</Form.Item>
<Form.Item label="Icon" name="icon">
<Input placeholder="IconLucideSettings" />
</Form.Item>
<Form.Item label="Sort" name="sort">
<InputNumber style={{ width: 180 }} />
</Form.Item>
<Form.Item label="Permissions (comma separated)" name="permissionsCsv">
<Input placeholder="admin:view,user:view" />
</Form.Item>
<Form.Item label="Hidden" name="hidden" valuePropName="checked">
<Switch />
</Form.Item>
</Form>
) : (
<Text type="secondary">Select one menu node to edit, or use the actions above to create nodes.</Text>
)}
</Card>
</Flex>
<Flex justify="flex-end" style={{ marginTop: token.margin }}>
<Button
type="primary"
loading={saveMutation.isPending}
onClick={() => {
void saveMutation.mutate(cloneMenus(menus));
}}
>
Save Menus
</Button>
</Flex>
</Card>
</Flex>
);
}
@@ -0,0 +1,137 @@
import { createFileRoute } from "@tanstack/react-router";
import { useQuery, useQueryClient, useMutation } from "@tanstack/react-query";
import { App, Badge, Button, Flex, List, Segmented, Space, Tag, Tooltip, Typography, theme } from "antd";
import { AtSign, CheckCheck, ClipboardList, Info, AlertTriangle, Trash2 } from "lucide-react";
import { useMemo, useState } from "react";
import { httpClient } from "@/utils/http";
import { NOTIFICATION_ENDPOINTS } from "@/api/notification";
import { NotificationSchema } from "@/api/schemas-ext";
import type { Notification } from "@/api/schemas-ext";
export const Route = createFileRoute("/_auth/notifications/")({ component: NotificationsPage });
const TYPE_META = {
system: { Icon: Info, color: "blue", label: "System" },
mention: { Icon: AtSign, color: "purple", label: "Mention" },
task: { Icon: ClipboardList, color: "green", label: "Task" },
alert: { Icon: AlertTriangle, color: "red", label: "Alert" },
} as const;
function NotificationsPage() {
const { token } = theme.useToken();
const { message } = App.useApp();
const qc = useQueryClient();
const [type, setType] = useState("all");
const { data: raw = [], isLoading } = useQuery({
queryKey: ["notifications", type],
queryFn: () =>
httpClient.get<Notification[]>(NOTIFICATION_ENDPOINTS.list, {
params: type !== "all" ? { type } : undefined,
}),
});
const items = useMemo(
() => raw
.filter((n) => NotificationSchema.safeParse(n).success)
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()),
[raw],
);
const inv = () => { void qc.invalidateQueries({ queryKey: ["notifications"] }); };
const mark = useMutation({
mutationFn: (id: string) => httpClient.put(NOTIFICATION_ENDPOINTS.markRead(id)),
onSuccess: inv,
});
const markAll = useMutation({
mutationFn: () => httpClient.put(NOTIFICATION_ENDPOINTS.markAllRead),
onSuccess: () => { message.success("All marked as read"); inv(); },
});
const del = useMutation({
mutationFn: (id: string) => httpClient.delete(NOTIFICATION_ENDPOINTS.delete(id)),
onSuccess: () => { message.success("Deleted"); inv(); },
});
const unread = items.filter((n) => !n.read).length;
return (
<Flex vertical gap={token.marginMD} style={{ flex: 1, minHeight: 0 }}>
<Flex justify="space-between" align="center" wrap="wrap" gap={token.marginSM}>
<Typography.Title level={4} style={{ margin: 0 }}>
Notifications
{unread > 0 && <Badge count={unread} style={{ marginInlineStart: token.marginSM }} overflowCount={99} />}
</Typography.Title>
<Space>
<Segmented
size="small"
value={type}
onChange={(v) => setType(v as string)}
options={[
{ label: "All", value: "all" },
{ label: "Unread", value: "unreadOnly" },
...Object.entries(TYPE_META).map(([k, m]) => ({ label: m.label, value: k })),
]}
/>
{unread > 0 && (
<Button size="small" icon={<CheckCheck size={token.fontSize} />}
onClick={() => markAll.mutate()} loading={markAll.isPending}>
Mark all read
</Button>
)}
</Space>
</Flex>
<List
loading={isLoading}
dataSource={items}
style={{ flex: 1, overflow: "auto" }}
locale={{ emptyText: "No notifications" }}
renderItem={(item) => {
const { Icon, color } = TYPE_META[item.type];
return (
<List.Item
key={item.id}
onClick={() => !item.read && mark.mutate(item.id)}
style={{
padding: `${token.paddingSM}px ${token.padding}px`,
borderRadius: token.borderRadiusLG,
background: item.read ? "transparent" : token.colorFillQuaternary,
cursor: item.read ? "default" : "pointer",
marginBottom: token.marginXXS,
}}
actions={[
<Tooltip title="Delete" key="x">
<Button type="text" size="small" danger icon={<Trash2 size={token.fontSize} />}
onClick={(e) => { e.stopPropagation(); del.mutate(item.id); }} />
</Tooltip>,
]}
>
<List.Item.Meta
avatar={<Tag color={color} style={{ display: "inline-flex", alignItems: "center", gap: 4, marginInlineEnd: 0 }}><Icon size={12} /></Tag>}
title={
<Flex align="center" gap={token.marginXS}>
{!item.read && <Badge status="processing" style={{ flexShrink: 0 }} />}
<Typography.Text strong={!item.read}>{item.title}</Typography.Text>
</Flex>
}
description={
<Flex vertical gap={4}>
<Typography.Paragraph type="secondary" ellipsis={{ rows: 2 }} style={{ margin: 0, fontSize: token.fontSizeSM }}>
{item.content}
</Typography.Paragraph>
<Typography.Text type="secondary" style={{ fontSize: 11 }}>
{new Date(item.createdAt).toLocaleString()}
</Typography.Text>
</Flex>
}
/>
</List.Item>
);
}}
/>
</Flex>
);
}
@@ -0,0 +1,300 @@
import { createFileRoute } from "@tanstack/react-router";
import { useMutation } from "@tanstack/react-query";
import {
App, Button, Card, Col, Divider, Flex, Form, Input, Row, Typography,
theme, Descriptions, Tag, Avatar, Tabs, Space, Upload
} from "antd";
import {
Mail, Phone, Building, Shield, Clock, Upload as UploadIcon,
Save, Key
} from "lucide-react";
import { useState } from "react";
import { httpClient } from "@/utils/http";
import { PROFILE_ENDPOINTS } from "@/api/profile";
import { useAuthStore } from "@/stores/auth";
import { UpdateProfileSchema, ChangePasswordSchema } from "@/api/schemas-ext";
import type { UpdateProfile, ChangePassword } from "@/api/schemas-ext";
import { UserSchema } from "@/api/schemas";
export const Route = createFileRoute("/_auth/profile/")({
component: ProfilePage,
});
function ProfilePage() {
const { token } = theme.useToken();
const { message } = App.useApp();
const user = useAuthStore((s) => s.user);
const setUser = useAuthStore((s) => s.setUser);
const [profileForm] = Form.useForm();
const [passwordForm] = Form.useForm();
const [editing, setEditing] = useState(false);
const profileMutation = useMutation({
mutationFn: (values: UpdateProfile) =>
httpClient.put(PROFILE_ENDPOINTS.update, UpdateProfileSchema.parse(values)),
onSuccess: (data) => {
const validated = UserSchema.parse(data);
setUser(validated);
setEditing(false);
message.success("Profile updated");
},
onError: (err) => {
message.error(err instanceof Error ? err.message : "Update failed");
},
});
const passwordMutation = useMutation({
mutationFn: (values: ChangePassword) =>
httpClient.put(
PROFILE_ENDPOINTS.changePassword,
ChangePasswordSchema.parse(values),
),
onSuccess: () => {
message.success("Password changed");
passwordForm.resetFields();
},
onError: (err) => {
message.error(err instanceof Error ? err.message : "Change password failed");
},
});
const avatarMutation = useMutation({
mutationFn: async (file: File) => {
// Simulated upload
await new Promise((r) => setTimeout(r, 1000));
return { url: URL.createObjectURL(file) };
},
onSuccess: (data) => {
// Optimistically update avatar
if (user) {
setUser({ ...user, avatar: data.url });
}
message.success("Avatar updated");
},
});
if (!user) {
return (
<Flex justify="center" align="center" style={{ minHeight: 200 }}>
<Typography.Text type="secondary">Loading profile...</Typography.Text>
</Flex>
);
}
const tabItems = [
{
key: "info",
label: "Profile",
children: (
<Flex vertical gap={token.marginMD}>
<Flex align="center" gap={token.marginLG} wrap="wrap">
<Upload
showUploadList={false}
accept="image/*"
customRequest={({ file }) => {
avatarMutation.mutate(file as File);
}}
>
<div style={{ position: "relative", cursor: "pointer" }}>
<Avatar
size={80}
src={user.avatar}
shape="circle"
style={{
border: `2px solid ${token.colorBorderSecondary}`,
}}
>
{user.username?.[0]?.toUpperCase()}
</Avatar>
<div
style={{
position: "absolute",
bottom: 0,
right: 0,
background: token.colorPrimary,
borderRadius: "50%",
padding: 4,
display: "flex",
}}
>
<UploadIcon size={12} style={{ color: "#fff" }} />
</div>
</div>
</Upload>
<Flex vertical gap={4}>
<Typography.Title level={4} style={{ margin: 0 }}>
{user.realName || user.username}
</Typography.Title>
<Typography.Text type="secondary">
@{user.username}
</Typography.Text>
<Space size={4}>
{user.roles.map((r) => (
<Tag key={r} color="blue" style={{ textTransform: "capitalize" }}>
{r}
</Tag>
))}
</Space>
</Flex>
</Flex>
<Divider style={{ margin: 0 }} />
{!editing ? (
<>
<Descriptions size="small" column={{ xs: 1, sm: 2 }} bordered>
<Descriptions.Item label={<><Mail size={12} /> Email</>}>
{user.email || "—"}
</Descriptions.Item>
<Descriptions.Item label={<><Phone size={12} /> Mobile</>}>
{user.mobile || "—"}
</Descriptions.Item>
<Descriptions.Item label={<><Building size={12} /> Department</>}>
{user.department || "—"}
</Descriptions.Item>
<Descriptions.Item label={<><Shield size={12} /> Role</>}>
{user.role || "—"}
</Descriptions.Item>
<Descriptions.Item label="Nickname">
{user.nickName || "—"}
</Descriptions.Item>
<Descriptions.Item label="Remark">
{user.remark || "—"}
</Descriptions.Item>
<Descriptions.Item label={<><Clock size={12} /> Last Login</>}>
{user.lastLogin
? new Date(user.lastLogin).toLocaleString()
: "—"}
</Descriptions.Item>
<Descriptions.Item label="Created By">
{user.createdBy || "—"}
</Descriptions.Item>
</Descriptions>
<Button
type="primary"
onClick={() => {
profileForm.setFieldsValue({
email: user.email,
mobile: user.mobile,
department: user.department,
nickName: user.nickName,
realName: user.realName,
remark: user.remark,
});
setEditing(true);
}}
style={{ width: "fit-content" }}
>
Edit Profile
</Button>
</>
) : (
<Form
form={profileForm}
layout="vertical"
onFinish={(values) => profileMutation.mutate(values)}
style={{ maxWidth: 480 }}
>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="realName" label="Real Name">
<Input />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="nickName" label="Nickname">
<Input />
</Form.Item>
</Col>
</Row>
<Form.Item name="email" label="Email" rules={[{ type: "email" }]}>
<Input />
</Form.Item>
<Form.Item name="mobile" label="Mobile">
<Input />
</Form.Item>
<Form.Item name="department" label="Department">
<Input />
</Form.Item>
<Form.Item name="remark" label="Remark">
<Input.TextArea rows={2} />
</Form.Item>
<Space>
<Button
type="primary"
htmlType="submit"
loading={profileMutation.isPending}
icon={<Save size={token.fontSize} />}
>
Save
</Button>
<Button onClick={() => {
setEditing(false);
profileForm.resetFields();
}}>
Cancel
</Button>
</Space>
</Form>
)}
</Flex>
),
},
{
key: "password",
label: "Change Password",
children: (
<Form
form={passwordForm}
layout="vertical"
onFinish={(values) => passwordMutation.mutate(values)}
style={{ maxWidth: 400 }}
>
<Form.Item
name="oldPassword"
label="Current Password"
rules={[{ required: true, message: "Please enter current password" }]}
>
<Input.Password placeholder="••••••" />
</Form.Item>
<Form.Item
name="newPassword"
label="New Password"
rules={[
{ required: true, message: "Please enter new password" },
{ min: 6, message: "Minimum 6 characters" },
]}
>
<Input.Password placeholder="At least 6 characters" />
</Form.Item>
<Form.Item
name="confirmPassword"
label="Confirm Password"
rules={[{ required: true, message: "Please confirm new password" }]}
>
<Input.Password placeholder="••••••" />
</Form.Item>
<Button
type="primary"
htmlType="submit"
loading={passwordMutation.isPending}
icon={<Key size={token.fontSize} />}
>
Change Password
</Button>
</Form>
),
},
];
return (
<Flex vertical gap={token.marginMD} style={{ flex: 1, minHeight: 0 }}>
<Typography.Title level={4} style={{ margin: 0 }}>
Profile
</Typography.Title>
<Card>
<Tabs items={tabItems} />
</Card>
</Flex>
);
}
@@ -1,4 +1,4 @@
import { Form, Input, Select } from "antd";
import { Form, Input } from "antd";
import type { FormInstance } from "antd/es/form";
import type { CreateUserRequest, User } from "@/api/schemas";
import { BaseFormModal } from "@/components/FormModal";
@@ -39,17 +39,10 @@ export function FormModal({
<Input />
</Form.Item>
<Form.Item
name="roles"
label="Roles"
rules={[{ required: true, message: "Please select roles" }]}
name="mobile"
label="Mobile"
>
<Select
mode="multiple"
options={[
{ label: "Admin", value: "admin" },
{ label: "Editor", value: "editor" },
]}
/>
<Input />
</Form.Item>
<Form.Item name="email" label="Email">
<Input />
+11 -34
View File
@@ -1,9 +1,8 @@
import { Button, Input, Select, theme } from "antd";
import { Plus, UserRound } from "lucide-react";
import { Button, Input, theme } from "antd";
import { Plus } from "lucide-react";
import { forwardRef, useMemo } from "react";
import { FilterToolbar } from "@/components/FilterToolbar";
/** Search + role slot `minWidth` for FilterToolbar collapse math */
const FILTER_CONTROL_WIDTH = 220;
export type ToolbarProps = {
@@ -11,21 +10,12 @@ export type ToolbarProps = {
onKeywordChange: (value: string) => void;
onSearch: (keyword: string) => void;
onClearSearch: () => void;
roleValue: string | undefined;
onRoleChange: (role: string) => void;
canCreate: boolean;
onCreateClick: () => void;
};
export const Toolbar = forwardRef<HTMLDivElement, ToolbarProps>(function Toolbar(
{
keywordInput,
onKeywordChange,
onSearch,
onClearSearch,
roleValue,
onRoleChange,
onCreateClick,
},
{ keywordInput, onKeywordChange, onSearch, onClearSearch, canCreate, onCreateClick },
ref,
) {
const { token } = theme.useToken();
@@ -47,26 +37,8 @@ export const Toolbar = forwardRef<HTMLDivElement, ToolbarProps>(function Toolbar
/>
),
},
{
key: "role",
minWidth: FILTER_CONTROL_WIDTH,
children: (
<Select
allowClear
placeholder="Role"
style={{ width: FILTER_CONTROL_WIDTH }}
prefix={<UserRound size={token.fontSize} />}
value={roleValue}
onChange={(v) => onRoleChange(v ?? "")}
options={[
{ label: "Admin", value: "admin" },
{ label: "Editor", value: "editor" },
]}
/>
),
},
],
[keywordInput, onClearSearch, onKeywordChange, onRoleChange, onSearch, roleValue, token.fontSize],
[keywordInput, onClearSearch, onKeywordChange, onSearch],
);
return (
@@ -74,7 +46,12 @@ export const Toolbar = forwardRef<HTMLDivElement, ToolbarProps>(function Toolbar
ref={ref}
slots={slots}
actions={
<Button type="primary" icon={<Plus size={token.fontSize} />} onClick={onCreateClick}>
<Button
type="primary"
icon={<Plus size={token.fontSize} />}
onClick={onCreateClick}
disabled={!canCreate}
>
Create User
</Button>
}
+58 -52
View File
@@ -1,5 +1,5 @@
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { Avatar, Button, Space, Form, App, Dropdown, theme, Tag, Flex } from "antd";
import { Avatar, Button, Form, App, Dropdown, theme, Flex } from "antd";
import type { TablePaginationConfig } from "antd/es/table/interface";
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { httpClient } from "@/utils/http";
@@ -10,16 +10,17 @@ import { z } from "zod/v4";
import { MoreVertical, Pencil, Trash2 } from "lucide-react";
import { DataTable } from "@/components/DataTable";
import { useResourceCRUD } from "@/hooks/useResourceCRUD";
import { useAuthStore } from "@/stores/auth";
import { Toolbar } from "./-Toolbar";
import { FormModal } from "./-FormModal";
const UserSearchParamsSchema = z.object({
limit: z.number().int().positive().catch(100),
offset: z.number().int().nonnegative().catch(0),
sortField: z.string().nullable().catch(null),
sortOrder: z.enum(["ascend", "descend"]).nullable().catch(null),
sortField: z.string().nullable().optional().catch(null),
sortOrder: z.enum(["ascend", "descend"]).nullable().optional().catch(null),
keyword: z.string().catch(""),
role: z.string().catch(""),
role: z.string().catch("")
});
export const Route = createFileRoute("/_auth/users/")({
@@ -58,6 +59,10 @@ function UsersPage() {
});
const [editingUser, setEditingUser] = useState<User | null>(null);
const [form] = Form.useForm();
const hasPermission = useAuthStore((s) => s.hasPermission);
const canCreate = hasPermission("user:create");
const canEdit = hasPermission("user:edit");
const canDelete = hasPermission("user:delete");
const [keywordInput, setKeywordInput] = useState(search.keyword);
const currentPage = Math.floor(search.offset / search.limit) + 1;
@@ -215,27 +220,20 @@ function UsersPage() {
title: "Roles",
dataIndex: "roles",
key: "roles",
sorter: true,
sortOrder: search.sortField === "roles" ? search.sortOrder : null,
render: (roles: string[]) => (
<Space wrap>
{roles.map((role) => (
<Tag
key={role}
variant="outlined"
styles={{
root: {
borderRadius: 9999,
background: "transparent",
boxShadow: "none",
},
}}
>
{role}
</Tag>
))}
</Space>
),
render: (_: unknown, record: User) => {
if (!record.roles || record.roles.length === 0) return "—";
return (
<span style={{ whiteSpace: "nowrap" }}>
{record.roles.join(", ")}
</span>
);
},
},
{
title: "Mobile",
dataIndex: "mobile",
key: "mobile",
render: (mobile: string | null | undefined) => mobile || "—",
},
{
title: "Actions",
@@ -243,11 +241,10 @@ function UsersPage() {
width: 60,
align: "right" as const,
sorter: false,
render: (_: unknown, record: User) => (
<Dropdown
menu={{
items: [
{
render: (_: unknown, record: User) => {
const items = [
canEdit
? {
key: "edit",
icon: <Pencil size={token.fontSize} />,
label: "Edit",
@@ -256,21 +253,29 @@ function UsersPage() {
form.setFieldsValue(record);
setModalOpen(true);
},
},
{
}
: null,
canDelete
? {
key: "delete",
icon: <Trash2 size={token.fontSize} />,
label: "Delete",
danger: true,
onClick: () => confirmDelete(record),
},
],
}}
placement="bottomRight"
>
<Button type="text" icon={<MoreVertical size={token.fontSize} />} />
</Dropdown>
),
}
: null,
].filter((item): item is NonNullable<typeof item> => item != null);
if (!items.length) {
return <span style={{ color: token.colorTextSecondary }}>—</span>;
}
return (
<Dropdown menu={{ items }} placement="bottomRight">
<Button type="text" icon={<MoreVertical size={token.fontSize} />} />
</Dropdown>
);
},
},
];
@@ -420,17 +425,9 @@ function UsersPage() {
setKeywordInput("");
applySearch("");
}}
roleValue={search.role || undefined}
onRoleChange={(role) =>
void navigate({
search: {
...search,
role,
offset: 0,
},
})
}
canCreate={canCreate}
onCreateClick={() => {
if (!canCreate) return;
setEditingUser(null);
form.resetFields();
setModalOpen(true);
@@ -455,8 +452,9 @@ function UsersPage() {
scroll={tableScrollY != null ? { x: "max-content", y: tableScrollY } : { x: "max-content" }}
onChange={(_pagination, _filters, sorter) => {
if (Array.isArray(sorter)) return;
const nextSortField = sorter.order ? String(sorter.field) : "username";
const nextSortOrder = sorter.order ? sorter.order : "descend";
// 修正排序回退逻辑:无排序时清空 sortField/sortOrder,否则用当前字段和顺序
const nextSortField = sorter.order ? String(sorter.field) : undefined;
const nextSortOrder = sorter.order ? sorter.order : undefined;
void navigate({
search: {
...search,
@@ -479,8 +477,16 @@ function UsersPage() {
}}
onFinish={(values) => {
if (editingUser) {
if (!canEdit) {
message.error("No permission to edit users");
return;
}
updateMutation.mutate({ ...values, id: editingUser.id });
} else {
if (!canCreate) {
message.error("No permission to create users");
return;
}
createMutation.mutate(values);
}
}}
+13 -8
View File
@@ -1,4 +1,4 @@
import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router";
import { Link, createFileRoute, redirect, useNavigate } from "@tanstack/react-router";
import { Form, Input, Button, Card, App, theme, Typography, Flex, Checkbox, Space } from "antd";
import type { CSSProperties } from "react";
import { useMutation } from "@tanstack/react-query";
@@ -171,13 +171,9 @@ function LoginPage() {
<Form.Item name="remember" valuePropName="checked" noStyle>
<Checkbox>Auto login</Checkbox>
</Form.Item>
<Typography.Link
href="#"
onClick={(e) => e.preventDefault()}
style={{ fontSize: token.fontSizeSM }}
>
Forgot password?
</Typography.Link>
<Typography.Text type="secondary" style={{ fontSize: token.fontSizeSM }}>
Demo: admin/admin or guest/guest
</Typography.Text>
</Flex>
<Form.Item style={{ marginBottom: 0, marginTop: token.marginLG }}>
@@ -191,6 +187,15 @@ function LoginPage() {
Sign In
</Button>
</Form.Item>
<Flex justify="center" style={{ marginTop: token.margin }}>
<Typography.Text type="secondary">
No account?{" "}
<Link to="/register" style={{ color: token.colorPrimary }}>
Create one
</Link>
</Typography.Text>
</Flex>
</Form>
</Card>
</Flex>
+198
View File
@@ -0,0 +1,198 @@
import { Link, createFileRoute, redirect, useNavigate } from "@tanstack/react-router";
import { Form, Input, Button, Card, App, theme, Typography, Flex } from "antd";
import type { CSSProperties } from "react";
import { useMutation } from "@tanstack/react-query";
import { httpClient } from "@/utils/http";
import { useAuthStore } from "@/stores/auth";
import { AUTH_ENDPOINTS } from "@/api/auth";
import { AuthTokensSchema, RegisterRequestSchema } from "@/api/schemas";
import { fetchSessionAndApplyToStore } from "@/utils/session";
import type { RegisterRequest } from "@/api/auth";
import { APP_BRAND_NAME, APP_FAVICON_SRC } from "@/utils/constants";
import { Aurora } from "@/components/Aurora";
import "../login/index.css";
export const Route = createFileRoute("/register/")({
beforeLoad: () => {
const { isAuthenticated } = useAuthStore.getState();
if (isAuthenticated) {
throw redirect({ to: "/dashboard" });
}
},
component: RegisterPage,
});
function RegisterPage() {
const navigate = useNavigate();
const { message } = App.useApp();
const setTokens = useAuthStore((s) => s.setTokens);
const { token } = theme.useToken();
const registerMutation = useMutation({
mutationFn: async (values: RegisterRequest) => {
const parsed = RegisterRequestSchema.parse(values);
const tokens = await httpClient.post(AUTH_ENDPOINTS.register, parsed);
const validTokens = AuthTokensSchema.parse(tokens);
setTokens(validTokens);
await fetchSessionAndApplyToStore();
},
onSuccess: () => {
message.success("Register successful");
void navigate({ to: "/dashboard" });
},
onError: (err) => {
message.error(err instanceof Error ? err.message : "Register failed");
},
});
const shellStyle: CSSProperties = {
position: "relative",
isolation: "isolate",
minHeight: "100vh",
backgroundColor: token.colorBgLayout,
color: token.colorText,
};
const contentStyle: CSSProperties = {
position: "relative",
zIndex: 1,
flex: "1 1 0%",
minWidth: 0,
minHeight: 0,
overflow: "hidden",
};
const cardStyle: CSSProperties = {
width: "100%",
maxWidth: 384,
background: `color-mix(in srgb, ${token.colorBgContainer} 44%, transparent)`,
backdropFilter: "blur(18px) saturate(1.35)",
WebkitBackdropFilter: "blur(18px) saturate(1.35)",
borderColor: token.colorBorderSecondary,
boxShadow: token.boxShadow,
["--login-card-fallback-bg" as string]: token.colorBgElevated,
};
return (
<Flex vertical style={shellStyle}>
<Aurora />
<Flex vertical style={contentStyle}>
<Flex flex={1} align="center" justify="center" style={{ padding: token.padding, minHeight: 0 }}>
<Card
className="login-page__card"
style={cardStyle}
styles={{
body: { padding: token.paddingLG, background: "transparent" },
}}
>
<Flex
align="center"
justify="center"
gap={token.margin}
wrap="wrap"
style={{ marginBottom: token.marginLG }}
>
<img
src={APP_FAVICON_SRC}
alt=""
width={32}
height={32}
draggable={false}
style={{ display: "block", flexShrink: 0 }}
/>
<Typography.Title
level={3}
style={{
margin: 0,
fontWeight: "bold",
letterSpacing: "-0.025em",
lineHeight: 1.2,
textTransform: "uppercase",
}}
>
{APP_BRAND_NAME}
</Typography.Title>
</Flex>
<Form
layout="vertical"
onFinish={(values) => {
const payload = RegisterRequestSchema.parse(values);
registerMutation.mutate(payload);
}}
initialValues={{ username: "", password: "", confirmPassword: "", email: "" }}
requiredMark={false}
>
<Form.Item
name="username"
label={<span style={{ fontWeight: 500 }}>Username</span>}
rules={[{ required: true, message: "Please enter username" }]}
>
<Input placeholder="new-user" size="large" />
</Form.Item>
<Form.Item
name="email"
label={<span style={{ fontWeight: 500 }}>Email (optional)</span>}
rules={[{ type: "email", message: "Please enter a valid email" }]}
>
<Input placeholder="you@example.com" size="large" />
</Form.Item>
<Form.Item
name="password"
label={<span style={{ fontWeight: 500 }}>Password</span>}
rules={[
{ required: true, message: "Please enter password" },
{ min: 6, message: "Password must be at least 6 characters" },
]}
>
<Input.Password placeholder="At least 6 characters" size="large" />
</Form.Item>
<Form.Item
name="confirmPassword"
label={<span style={{ fontWeight: 500 }}>Confirm Password</span>}
dependencies={["password"]}
rules={[
{ required: true, message: "Please confirm password" },
({ getFieldValue }) => ({
validator(_, value) {
if (!value || getFieldValue("password") === value) {
return Promise.resolve();
}
return Promise.reject(new Error("Passwords do not match"));
},
}),
]}
>
<Input.Password placeholder="Repeat your password" size="large" />
</Form.Item>
<Form.Item style={{ marginBottom: 0, marginTop: token.marginLG }}>
<Button
type="primary"
htmlType="submit"
loading={registerMutation.isPending}
block
size="large"
>
Create Account
</Button>
</Form.Item>
<Flex justify="center" style={{ marginTop: token.margin }}>
<Typography.Text type="secondary">
Already have an account?{" "}
<Link to="/login" style={{ color: token.colorPrimary }}>
Sign in
</Link>
</Typography.Text>
</Flex>
</Form>
</Card>
</Flex>
</Flex>
</Flex>
);
}
+28
View File
@@ -0,0 +1,28 @@
import { createPersistentStore } from "./createPersistentStore";
type AIProvider = "openai" | "azure-openai" | "anthropic";
interface AIState {
enabled: boolean;
provider: AIProvider;
model: string;
systemPrompt: string;
temperature: number;
maxTokens: number;
setConfig: (next: Partial<Omit<AIState, "setConfig">>) => void;
}
export const useAIStore = createPersistentStore<AIState>(
(set) => ({
enabled: true,
provider: "openai",
model: "gpt-5.3-codex",
systemPrompt: "You are an enterprise assistant for admin operations.",
temperature: 0.2,
maxTokens: 2048,
setConfig: (next) => set((state) => ({ ...state, ...next })),
}),
{
name: "ai-settings-storage",
},
);
+70 -19
View File
@@ -1,5 +1,16 @@
import type { MenuItem } from "@/api/schemas";
export const APP_PERMISSION_OPTIONS = [
{ label: "Users: View", value: "user:view" },
{ label: "Users: Create", value: "user:create" },
{ label: "Users: Edit", value: "user:edit" },
{ label: "Users: Delete", value: "user:delete" },
{ label: "Admins: View", value: "admin:view" },
{ label: "Admins: Create", value: "admin:create" },
{ label: "Admins: Edit", value: "admin:edit" },
{ label: "Admins: Delete", value: "admin:delete" },
] as const;
/**
* Built-in menu tree. Visibility is computed with {@link filterMenuTreeByPermissions}
* from `GET /api/auth/permissions` (must stay consistent with route permission map below).
@@ -37,41 +48,74 @@ export const APP_MENU_TREE: MenuItem[] = [
sort: 1,
hidden: false,
},
{
id: "5",
kind: "item",
name: "Admins",
path: "/admins",
icon: "IconLucideShieldCheck",
children: null,
permissions: ["admin:view"],
sort: 2,
hidden: false,
},
{
id: "8",
kind: "item",
name: "Notifications",
path: "/notifications",
icon: "IconLucideHistory",
children: null,
permissions: null,
sort: 3,
hidden: false,
},
{
id: "10",
kind: "item",
name: "Logs",
path: "/logs",
icon: "IconLucideBookOpen",
children: null,
permissions: ["admin:view"],
sort: 5,
hidden: false,
},
],
},
{
id: "g-projects",
id: "g-settings",
kind: "group",
name: "Projects",
name: "Settings",
path: null,
icon: "IconLucideFolderKanban",
icon: "IconLucideSettings",
permissions: ["admin:view"],
sort: 1,
hidden: false,
children: [
{
id: "3",
id: "6",
kind: "item",
name: "Design Engineering",
path: "/design-engineering",
icon: "IconLucideBookOpen",
name: "Menus",
path: "/menus",
icon: "IconLucideSettings",
children: null,
permissions: null,
permissions: ["admin:view"],
sort: 0,
hidden: false,
},
{
id: "4",
id: "7",
kind: "item",
name: "Sales & Marketing",
path: "/sales-marketing",
icon: "IconLucideBriefcase",
name: "AI Settings",
path: "/ai-settings",
icon: "IconLucideSparkles",
children: null,
permissions: ["user:view"],
permissions: ["admin:view"],
sort: 1,
hidden: false,
},
],
permissions: null,
sort: 1,
hidden: false,
},
];
@@ -123,13 +167,20 @@ export function normalizeAppPath(pathname: string): string {
* Route → permission required to open the page. `null` = no permission gate.
* Keep in sync with {@link APP_MENU_TREE} paths.
*/
// 更严格的路径权限判断,避免误判/绕过
export function requiredPermissionForPath(pathname: string): string | null {
const p = normalizeAppPath(pathname);
// 只允许精确匹配和一级子路由
if (p === "/admins" || /^\/admins\/[\w-]+$/.test(p)) return "admin:view";
if (p === "/users" || /^\/users\/[\w-]+$/.test(p)) return "user:view";
const map: Record<string, string | null> = {
"/dashboard": null,
"/users": "user:view",
"/design-engineering": null,
"/sales-marketing": "user:view",
"/notifications": null,
"/logs": "admin:view",
"/profile": null,
"/menus": "admin:view",
"/ai-settings": "admin:view",
"/403": null,
};
return map[p] ?? null;
+112
View File
@@ -0,0 +1,112 @@
/**
* 全局异常上报工具
*
* 能力:
* - 分级记录错误(INFO / WARN / ERROR / FATAL)
* - 捕获 window.onerror / unhandledrejection
* - 支持自定义上报回调(接入 Sentry / 自建平台)
* - 在 ErrorBoundary 内复用
*/
export const ErrorSeverity = {
INFO: "info",
WARN: "warn",
ERROR: "error",
FATAL: "fatal",
} as const;
export type ErrorSeverity = (typeof ErrorSeverity)[keyof typeof ErrorSeverity];
interface ErrorContext {
componentStack?: string;
url?: string;
timestamp?: number;
[key: string]: unknown;
}
type ReportCallback = (
error: Error | string,
severity: ErrorSeverity,
context?: ErrorContext,
) => void;
let reportFn: ReportCallback | null = null;
/**
* 注册自定义上报回调。
* 不调用则默认只 console 输出。
*/
export function registerErrorReporter(fn: ReportCallback): void {
reportFn = fn;
}
/** 上报一个异常 */
export function reportError(
error: Error | string,
severity: ErrorSeverity = ErrorSeverity.ERROR,
context?: ErrorContext,
): void {
const err = typeof error === "string" ? new Error(error) : error;
const ctx: ErrorContext = {
url: typeof window !== "undefined" ? window.location.href : undefined,
timestamp: Date.now(),
...context,
};
// 默认 console 兜底
const consoleFn =
severity === ErrorSeverity.FATAL || severity === ErrorSeverity.ERROR
? console.error
: severity === ErrorSeverity.WARN
? console.warn
: console.info;
consoleFn("[ErrorReporter]", err, ctx);
reportFn?.(err, severity, ctx);
}
/** 上报一个 warning 级别的消息(不会打断用户交互) */
export function reportWarning(message: string, context?: ErrorContext): void {
reportError(message, ErrorSeverity.WARN, context);
}
/** 上报 info 级别的日志 */
export function reportInfo(message: string, context?: ErrorContext): void {
reportError(message, ErrorSeverity.INFO, context);
}
// —— 全局兜底捕获 ——
const WINDOW_CAPTURED_KEY = "__antd_admin_error_captured__";
function installGlobalErrorHandlers(): void {
if (typeof window === "undefined") return;
if ((window as unknown as Record<string, unknown>)[WINDOW_CAPTURED_KEY]) return;
(window as unknown as Record<string, unknown>)[WINDOW_CAPTURED_KEY] = true;
// 捕获未被 try-catch 覆盖的同步/异步错误
window.addEventListener("error", (event) => {
// 跳过资源加载错误(如图片 404),只处理 JS 异常
if (event.error) {
reportError(event.error, ErrorSeverity.FATAL, {
url: event.filename,
line: event.lineno,
col: event.colno,
message: event.message,
});
}
});
// 捕获未被 catch 的 Promise reject
window.addEventListener("unhandledrejection", (event) => {
const error =
event.reason instanceof Error
? event.reason
: new Error(String(event.reason));
reportError(error, ErrorSeverity.FATAL, {
message: "Unhandled Promise Rejection",
});
});
}
export { installGlobalErrorHandlers };
+276 -36
View File
@@ -1,4 +1,8 @@
import { API_BASE_URL } from "./constants";
import { useAuthStore } from "@/stores/auth";
import { reportWarning } from "@/utils/errorReporter";
// ── Error types ────────────────────────────────────────
export class ApiError extends Error {
code: number;
@@ -18,11 +22,127 @@ export class HttpError extends Error {
}
}
type RequestOptions = Omit<RequestInit, "method" | "body"> & {
params?: Record<string, string | number | null | undefined>;
};
// ── Types ──────────────────────────────────────────────
function buildUrl(path: string, params?: RequestOptions["params"]): string {
export interface RequestOptions extends Omit<RequestInit, "method" | "body" | "signal"> {
params?: Record<string, string | number | null | undefined>;
/**
* Number of retry attempts for transient failures (5xx, network errors).
* Default: 0 (no retry). Set to 1-3 for idempotent GET requests.
*/
retry?: number;
/** Base delay in ms for exponential backoff. Default: 1000. */
retryBaseMs?: number;
/**
* AbortController signal for request cancellation.
* Also available as `options.signal` (standard fetch).
*/
signal?: AbortSignal;
/** Skip automatic token refresh on 401. Default: false. */
skipAuthRefresh?: boolean;
}
// ── Token management ───────────────────────────────────
interface StoredAuth {
state?: {
tokens?: { accessToken: string; refreshToken: string } | null;
};
}
function readAuthFromStorage() {
try {
const raw = localStorage.getItem("auth-storage");
if (!raw) return null;
return JSON.parse(raw) as StoredAuth;
} catch {
return null;
}
}
function getAccessToken(): string | null {
return readAuthFromStorage()?.state?.tokens?.accessToken ?? null;
}
function getRefreshToken(): string | null {
return readAuthFromStorage()?.state?.tokens?.refreshToken ?? null;
}
function writeTokens(accessToken: string, refreshToken: string) {
const stored = readAuthFromStorage() ?? {};
stored.state = { ...stored.state, tokens: { accessToken, refreshToken } };
localStorage.setItem("auth-storage", JSON.stringify(stored));
}
/**
* Shared in-flight refresh promise.
* Multiple concurrent 401s should share one refresh call.
*/
let refreshPromise: Promise<string> | null = null;
async function refreshAccessToken(): Promise<string> {
if (refreshPromise) return refreshPromise;
refreshPromise = (async () => {
const refreshToken = getRefreshToken();
if (!refreshToken) {
throw new Error("No refresh token available");
}
const url = API_BASE_URL
? `${API_BASE_URL}/api/auth/refresh`
: "/api/auth/refresh";
let res: Response;
try {
res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refreshToken }),
});
} catch (err) {
// Network error during refresh → can't recover
useAuthStore.getState().logout();
throw err;
}
if (!res.ok) {
useAuthStore.getState().logout();
throw new HttpError(res.status, `Token refresh failed: ${res.statusText}`);
}
const json = await res.json();
if (json.code !== undefined && json.code !== 0) {
useAuthStore.getState().logout();
throw new ApiError(json.code, json.message ?? "Token refresh failed");
}
const data = json.data ?? json;
const { accessToken, refreshToken: newRefreshToken } = data as {
accessToken: string;
refreshToken: string;
};
writeTokens(accessToken, newRefreshToken);
// Also update in-memory store so Zustand subscribers are aware
const authStore = useAuthStore.getState();
authStore.setTokens({ accessToken, refreshToken: newRefreshToken });
return accessToken;
})().finally(() => {
refreshPromise = null;
});
return refreshPromise;
}
// ── Helpers ────────────────────────────────────────────
function buildUrl(
path: string,
params?: RequestOptions["params"],
): string {
const base = path.startsWith("http") ? path : `${API_BASE_URL}${path}`;
if (!params) return base;
const url = new URL(base, window.location.origin);
@@ -33,57 +153,177 @@ function buildUrl(path: string, params?: RequestOptions["params"]): string {
}
function getAuthHeaders(): Record<string, string> {
try {
const raw = localStorage.getItem("auth-storage");
if (!raw) return {};
const parsed = JSON.parse(raw);
const token = parsed?.state?.tokens?.accessToken;
if (token) return { Authorization: `Bearer ${token}` };
} catch {
// noop
}
return {};
const token = getAccessToken();
return token ? { Authorization: `Bearer ${token}` } : {};
}
/** Whether this fetch error is transient (retryable) */
function isTransientError(err: unknown): boolean {
if (err instanceof HttpError) {
// 5xx server errors are transient
return err.status >= 500 && err.status < 600;
}
if (err instanceof TypeError) {
// Network errors (fetch throws TypeError for network failures)
return true;
}
return false;
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
// ── Core request function ──────────────────────────────
async function request<T>(
method: string,
path: string,
body?: unknown,
options?: RequestOptions,
): Promise<T> {
const url = buildUrl(path, options?.params);
const headers: Record<string, string> = {
"Content-Type": "application/json",
...getAuthHeaders(),
...(options?.headers as Record<string, string>),
};
const {
params,
retry = 0,
retryBaseMs = 1000,
signal,
skipAuthRefresh = false,
headers: extraHeaders,
...restOptions
} = options ?? {};
const res = await fetch(url, {
method,
headers,
body: body ? JSON.stringify(body) : undefined,
...options,
});
const url = buildUrl(path, params);
const maxAttempts = retry + 1;
if (!res.ok) {
throw new HttpError(res.status, `HTTP ${res.status}: ${res.statusText}`);
for (let attempt = 0; attempt < maxAttempts; attempt++) {
const headers: Record<string, string> = {
"Content-Type": "application/json",
...getAuthHeaders(),
...(extraHeaders as Record<string, string>),
};
let res: Response;
try {
res = await fetch(url, {
method,
headers,
body: body ? JSON.stringify(body) : undefined,
signal, // Standard AbortSignal, fetch will throw AbortError on cancel
...restOptions,
});
} catch (err) {
// AbortError → rethrow immediately without retry
if (err instanceof DOMException && err.name === "AbortError") {
throw err;
}
const fetchErr =
err instanceof TypeError
? new HttpError(0, `Network error: ${(err as Error).message}`)
: err;
// Retry transient errors, but only on remaining attempts
if (attempt < maxAttempts - 1 && isTransientError(fetchErr)) {
const backoff = retryBaseMs * Math.pow(2, attempt);
reportWarning(
`Request failed (attempt ${attempt + 1}/${maxAttempts}), retrying in ${backoff}ms:\n` +
`${method} ${url}`,
);
await delay(backoff);
continue;
}
throw fetchErr;
}
// ── 401 → try refresh, then retry ──
if (res.status === 401 && !skipAuthRefresh) {
try {
const newToken = await refreshAccessToken();
// Retry with fresh token
const retryHeaders: Record<string, string> = {
"Content-Type": "application/json",
Authorization: `Bearer ${newToken}`,
...(extraHeaders as Record<string, string>),
};
const retryRes = await fetch(url, {
method,
headers: retryHeaders,
body: body ? JSON.stringify(body) : undefined,
signal,
...restOptions,
});
if (!retryRes.ok) {
throw new HttpError(
retryRes.status,
`HTTP ${retryRes.status}: ${retryRes.statusText}`,
);
}
const retryJson = await retryRes.json();
if (retryJson.code !== undefined && retryJson.code !== 0) {
throw new ApiError(
retryJson.code,
retryJson.message ?? "Unknown error",
);
}
return retryJson.data !== undefined ? retryJson.data : retryJson;
} catch (refreshErr) {
// refreshAccessToken calls logout() internally on failure
throw refreshErr;
}
}
// ── Non-401 error ──
if (!res.ok) {
const httpErr = new HttpError(
res.status,
`HTTP ${res.status}: ${res.statusText}`,
);
if (attempt < maxAttempts - 1 && isTransientError(httpErr)) {
const backoff = retryBaseMs * Math.pow(2, attempt);
reportWarning(
`Request failed (attempt ${attempt + 1}/${maxAttempts}), retrying in ${backoff}ms:\n` +
`${method} ${url}`,
);
await delay(backoff);
continue;
}
throw httpErr;
}
// ── Parse response ──
const json = await res.json();
if (json.code !== undefined && json.code !== 0) {
throw new ApiError(json.code, json.message ?? "Unknown error");
}
return json.data !== undefined ? json.data : json;
}
const json = await res.json();
if (json.code !== undefined && json.code !== 0) {
throw new ApiError(json.code, json.message ?? "Unknown error");
}
return json.data !== undefined ? json.data : json;
// Should never reach here, but TypeScript needs it
throw new HttpError(0, "Max retries exceeded");
}
// ── Public API ─────────────────────────────────────────
export const httpClient = {
get: <T>(path: string, options?: RequestOptions) => request<T>("GET", path, undefined, options),
get: <T>(path: string, options?: RequestOptions) =>
request<T>("GET", path, undefined, options),
post: <T>(path: string, body?: unknown, options?: RequestOptions) =>
request<T>("POST", path, body, options),
put: <T>(path: string, body?: unknown, options?: RequestOptions) =>
request<T>("PUT", path, body, options),
patch: <T>(path: string, body?: unknown, options?: RequestOptions) =>
request<T>("PATCH", path, body, options),
delete: <T>(path: string, options?: RequestOptions) =>
request<T>("DELETE", path, undefined, options),
};
+6 -3
View File
@@ -1,17 +1,20 @@
import { httpClient } from "@/utils/http";
import { AUTH_ENDPOINTS } from "@/api/auth";
import { MENU_ENDPOINTS } from "@/api/menu";
import { AuthUserResponseSchema, PermissionsListSchema, UserSchema } from "@/api/schemas";
import { APP_MENU_TREE, filterMenuTreeByPermissions } from "@/utils/appMenu";
import { MenuItemSchema } from "@/api/schemas";
import { filterMenuTreeByPermissions } from "@/utils/appMenu";
import { useAuthStore } from "@/stores/auth";
/** Fetches `/api/auth/user` and `/api/auth/permissions`, then updates `user` and sidebar `menus`. */
export async function fetchSessionAndApplyToStore(): Promise<void> {
const [userBase, permissions] = await Promise.all([
const [userBase, permissions, menuTree] = await Promise.all([
httpClient.get(AUTH_ENDPOINTS.user).then((d) => AuthUserResponseSchema.parse(d)),
httpClient.get(AUTH_ENDPOINTS.permissions).then((d) => PermissionsListSchema.parse(d)),
httpClient.get(MENU_ENDPOINTS.list).then((d) => MenuItemSchema.array().parse(d)),
]);
const user = UserSchema.parse({ ...userBase, permissions });
const menus = filterMenuTreeByPermissions(APP_MENU_TREE, permissions);
const menus = filterMenuTreeByPermissions(menuTree, permissions);
const { setUser, setMenus } = useAuthStore.getState();
setUser(user);
setMenus(menus);
+1 -1
View File
@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+7 -7
View File
@@ -9,19 +9,19 @@
"lint": "next lint"
},
"dependencies": {
"next": "^15.1.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"next": "^16.2.4",
"nextra": "^4.0.0",
"nextra-theme-docs": "^4.0.0"
"nextra-theme-docs": "^4.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
"@types/node": "^22.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"typescript": "~5.9.0",
"eslint": "^9.0.0",
"eslint-config-next": "^15.1.0"
"eslint": "^10.2.1",
"eslint-config-next": "^16.2.4",
"typescript": "~6.0.3"
}
}
+26 -6
View File
@@ -1,7 +1,11 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["dom", "dom.iterable", "esnext"],
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
@@ -11,11 +15,27 @@
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"jsx": "react-jsx",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": { "@/*": ["./*"] }
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./*"
]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}
+25
View File
@@ -14,3 +14,28 @@ export async function gotoUsers(page: Page) {
await page.goto("/users");
await expect(page).toHaveURL(/users/);
}
export async function verifyTableHeaders(page: Page): Promise<void> {
const thead = page.locator(".ant-table-thead");
await Promise.all([
thead.getByText(/Username|用户名/).isVisible(),
thead.getByText(/Email|邮箱/).isVisible(),
thead.getByText(/Roles|角色/).isVisible(),
]);
}
/** Search for a user by keyword */
export async function searchUser(page: Page, keyword: string): Promise<void> {
const searchInput = page.getByPlaceholder(/Search User|搜索用户/);
await searchInput.fill(keyword);
await searchInput.press("Enter");
await page.waitForURL((url) => url.searchParams.get("keyword") === keyword);
}
/** Verify role-based filtering */
export async function verifyRoleFilter(page: Page, role: string): Promise<void> {
await page.goto(`/users?role=${role}`);
await page.waitForSelector(".ant-table-thead");
const roleCell = page.getByRole("cell", { name: new RegExp(role, "i") });
await roleCell.first().isVisible();
}
+9 -19
View File
@@ -1,5 +1,10 @@
import { test, expect } from "@playwright/test";
import { gotoUsers, loginAsAdmin } from "./helpers";
import {
gotoUsers,
verifyTableHeaders,
searchUser,
verifyRoleFilter,
} from "./helpers";
test.describe("User Management", () => {
test.beforeEach(async ({ page }) => {
@@ -7,18 +12,11 @@ test.describe("User Management", () => {
});
test("should display user table", async ({ page }) => {
const thead = page.locator(".ant-table-thead");
await expect(thead.getByText(/Username|用户名/)).toBeVisible();
await expect(thead.getByText(/Email|邮箱/)).toBeVisible();
await expect(thead.getByText(/Roles|角色/)).toBeVisible();
await verifyTableHeaders(page);
});
test("should search users", async ({ page }) => {
const searchInput = page.getByPlaceholder(/Search User|搜索用户/);
await searchInput.fill("zhao.ming");
await searchInput.press("Enter");
await expect(page).toHaveURL(/keyword=zhao\.ming/);
/* Avoid strict-mode match on email cell containing substring "zhao.ming" */
await searchUser(page, "zhao.ming");
await expect(page.getByText("zhao.ming", { exact: true })).toBeVisible();
});
@@ -28,16 +26,8 @@ test.describe("User Management", () => {
});
});
/** Avoid a second full `page.goto` after `/users`: reload can land on login before auth rehydrates. */
test.describe("User Management — role in URL", () => {
test.beforeEach(async ({ page }) => {
await loginAsAdmin(page);
await page.goto("/users?role=admin");
await expect(page.getByPlaceholder(/Search User|搜索用户/)).toBeVisible({ timeout: 15_000 });
});
test("should filter users by role", async ({ page }) => {
await expect(page).toHaveURL(/role=admin/);
await expect(page.getByRole("cell", { name: "ops.admin@northstar.io" })).toBeVisible();
await verifyRoleFilter(page, "admin");
});
});
+7 -18
View File
@@ -20,10 +20,10 @@
"dependencies": {
"@lingui/core": "^5.9.3",
"@lingui/react": "^5.9.3",
"@tanstack/react-query": "^5.90.21",
"@tanstack/react-router": "^1.167.4",
"antd": "^6.3.3",
"lucide-react": "^1.7.0",
"@tanstack/react-query": "^5.100.3",
"@tanstack/react-router": "^1.168.24",
"antd": "^6.3.6",
"lucide-react": "^1.11.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"zod": "^4.3.6",
@@ -34,26 +34,15 @@
"@lingui/swc-plugin": "^5.11.0",
"@lingui/vite-plugin": "^5.9.3",
"@playwright/test": "^1.58.2",
"@tanstack/router-plugin": "^1.166.13",
"@tanstack/router-plugin": "^1.167.26",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react-swc": "^4.3.0",
"msw": "^2.12.12",
"typescript": "~5.9.3",
"msw": "^2.13.6",
"typescript": "~6.0.3",
"vite": "npm:@voidzero-dev/vite-plus-core@latest",
"vite-plus": "latest"
},
"packageManager": "pnpm@10.32.1",
"pnpm": {
"overrides": {
"vite": "npm:@voidzero-dev/vite-plus-core@latest",
"vitest": "npm:@voidzero-dev/vite-plus-test@latest"
},
"onlyBuiltDependencies": [
"esbuild",
"msw"
]
},
"msw": {
"workerDirectory": [
"public"
@@ -1,4 +1,4 @@
# create-antd-admin(packages/create)设计规格
# init-antd-admin(packages/create)设计规格
> 状态:已定稿待实现审阅
> 日期:2026-04-14
@@ -6,18 +6,18 @@
## 1. 背景与动机
在 monorepo 中新增 **`packages/create`**,以 npm 包 **`create-antd-admin`** 发布,提供与 [create-turbo](https://github.com/vercel/turborepo/tree/main/packages/create-turbo) 相近体验的命令行工具:从 **GitHub 上的 `apps/<example>`** 拉取模板,在用户本机生成 **独立可运行** 的前端应用,并支持 **交互式选择模板**、**包管理器选择与安装**、**项目名称 transforms**。
在 monorepo 中新增 **`packages/create`**,以 npm 包 **`init-antd-admin`** 发布,提供与 [create-turbo](https://github.com/vercel/turborepo/tree/main/packages/create-turbo) 相近体验的命令行工具:从 **GitHub 上的 `apps/<example>`** 拉取模板,在用户本机生成 **独立可运行** 的前端应用,并支持 **交互式选择模板**、**包管理器选择与安装**、**项目名称 transforms**。
## 2. 目标与非目标
### 2.1 目标(v1)
- 发布 **`create-antd-admin`**,`pnpm dlx create-antd-admin@latest` 可用;`bin` 指向构建产物 `dist/cli.js`;npm **`files`** 以 **`dist`**(及必要的内嵌清单)为主,对齐 create-turbo 的发布边界。
- 发布 **`init-antd-admin`**,`pnpm dlx init-antd-admin@latest` 可用;`bin` 指向构建产物 `dist/cli.js`;npm **`files`** 以 **`dist`**(及必要的内嵌清单)为主,对齐 create-turbo 的发布边界。
- **产物**:在当前工作区生成 **独立应用** 目录(或 `.`),不依赖用户已克隆本 monorepo。
- **示例来源**:**运行时**从 **GitHub** 拉取 archive/tarball,解压后取 **`apps/<example>`** 子树物化到目标目录(create-turbo 模式)。
- **示例清单**:**构建期**扫描本仓库 **`apps/*`**,生成 **`examples` 清单**写入 `dist`,**排除 `apps/docs`**;运行时短名示例必须落在该清单内。
- **CLI**:
- 支持 `pnpm dlx create-antd-admin@latest --example basic`(及清单中的其它示例名)。
- 支持 `pnpm dlx init-antd-admin@latest --example basic`(及清单中的其它示例名)。
- **未传 `--example`** 时走 **交互**(选示例、目标目录、项目名称等)。
- **`--example`** 支持 **短名** 或 **GitHub URL**;提供 **`--example-path`** 处理分支名含 `/` 等与 create-turbo 相同的边界。
- **包管理器**:支持 **npm / yarn / pnpm / bun**,探测本机可用性;默认执行 **install**;支持 **`--skip-install`**。
@@ -43,8 +43,8 @@
### 4.1 命名与入口
- 包名:**`create-antd-admin`**。
- `bin`:**`create-antd-admin`** → `dist/cli.js`。
- 包名:**`init-antd-admin`**。
- `bin`:**`init-antd-admin`** → `dist/cli.js`。
### 4.2 参数(v1)
@@ -110,7 +110,7 @@
| 话题 | 结论 |
|------|------|
| 包位置 | `packages/create`,发布名 `create-antd-admin`。 |
| 包位置 | `packages/create`,发布名 `init-antd-admin`。 |
| 示例列表 | 构建期扫描 `apps/*`,排除 `docs`(用户曾希望「扫描」;发布物上落实为构建期扫描,避免 dlx 用户机无 monorepo)。 |
| 产物位置 | 独立应用目录或 `.`(非向本仓库新增 `apps/<name>`)。 |
| 模板分发 | create-turbo 式 **GitHub 拉取**,非主打本地打包模板。 |
+14 -14
View File
@@ -1,8 +1,8 @@
# create-antd-admin(packages/create)Implementation Plan
# init-antd-admin(packages/create)Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add workspace package `packages/create`, publishable as npm `create-antd-admin`, which downloads `apps/<example>` from `zuiidea/antd-admin` on GitHub (tag aligned to CLI version), runs interactive or flag-driven prompts, applies `package.json` name transforms, installs with the user’s package manager, and initializes git by default.
**Goal:** Add workspace package `packages/create`, publishable as npm `init-antd-admin`, which downloads `apps/<example>` from `zuiidea/antd-admin` on GitHub (tag aligned to CLI version), runs interactive or flag-driven prompts, applies `package.json` name transforms, installs with the user’s package manager, and initializes git by default.
**Architecture:** A small Node ESM CLI built with `tsup` into `dist/`; `commander` parses flags; `@inquirer/prompts` fills gaps; `giget` downloads `github:zuiidea/antd-admin/apps/<example>#<ref>` where `<ref>` defaults to `v<cliVersion>` with env overrides; a post-build script writes `dist/examples.json` by scanning `../../apps/*` and excluding `docs`; transforms are plain filesystem + JSON rewrites; install uses `child_process.spawn` with stdio inherited.
@@ -41,7 +41,7 @@
| `packages/create/src/resolve-example-source.test.ts` | Tests for URL / path resolution |
| `packages/create/src/integration/create-pipeline.test.ts` | Mocked download fixture → transforms (no network) |
| `turbo.json` | Add `packages/create` to implicit discovery via workspace (no change if tasks already glob); ensure `build` `outputs` include `dist/**` for the new package (root `turbo.json` already has `dist/**`) |
| `README.md` | One subsection line under Templates: `pnpm dlx create-antd-admin@latest` |
| `README.md` | One subsection line under Templates: `pnpm dlx init-antd-admin@latest` |
---
@@ -55,13 +55,13 @@
```json
{
"name": "create-antd-admin",
"name": "init-antd-admin",
"version": "6.0.0",
"description": "Create an Antd Admin app from official examples",
"license": "MIT",
"type": "module",
"bin": {
"create-antd-admin": "./dist/cli.js"
"init-antd-admin": "./dist/cli.js"
},
"files": [
"dist"
@@ -125,7 +125,7 @@ Expected: lockfile updates; workspace links `packages/create`.
```bash
git add packages/create/package.json packages/create/tsconfig.json pnpm-lock.yaml
git commit -m "chore(create): scaffold create-antd-admin package"
git commit -m "chore(create): scaffold init-antd-admin package"
```
---
@@ -160,7 +160,7 @@ export default defineConfig({
```typescript
#!/usr/bin/env node
console.log("create-antd-admin stub");
console.log("init-antd-admin stub");
```
- [ ] **Step 3: Add `packages/create/scripts/write-examples-json.mjs`**
@@ -602,7 +602,7 @@ import { CliError } from "./errors.js";
export async function downloadExampleToTemp(
gigetSource: string
): Promise<string> {
const dir = path.join(os.tmpdir(), `create-antd-admin-${randomUUID()}`);
const dir = path.join(os.tmpdir(), `init-antd-admin-${randomUUID()}`);
try {
const { dir: outDir } = await downloadTemplate(gigetSource, {
dir,
@@ -797,7 +797,7 @@ export function tryGitInit(root: string, enabled: boolean): boolean {
spawnSync("git", ["add", "-A"], { cwd: root, stdio: "ignore" });
spawnSync(
"git",
["commit", "-m", "chore: initial commit from create-antd-admin"],
["commit", "-m", "chore: initial commit from init-antd-admin"],
{ cwd: root, stdio: "ignore" }
);
return true;
@@ -935,7 +935,7 @@ import { CliError } from "./errors.js";
const program = new Command();
program
.name("create-antd-admin")
.name("init-antd-admin")
.description("Create an Antd Admin app from official examples")
.argument("[project-directory]", "Directory for the new project")
.option(
@@ -1084,7 +1084,7 @@ git commit -m "test(create): integration test for transforms without network"
Run:
```bash
cd /Users/zuiidea/web/antd-admin && pnpm turbo run build --filter=create-antd-admin
cd /Users/zuiidea/web/antd-admin && pnpm turbo run build --filter=init-antd-admin
```
Expected: SUCCESS.
@@ -1094,7 +1094,7 @@ Expected: SUCCESS.
```markdown
**CLI:** Scaffold a standalone app from an official example:
`pnpm dlx create-antd-admin@latest`
`pnpm dlx init-antd-admin@latest`
```
- [ ] **Step 3: Add `prepublishOnly` to `packages/create/package.json`**
@@ -1107,14 +1107,14 @@ Expected: SUCCESS.
```bash
git add README.md packages/create/package.json
git commit -m "docs: document create-antd-admin dlx usage; prepublish build"
git commit -m "docs: document init-antd-admin dlx usage; prepublish build"
```
---
## Release checklist (manual, not a code task)
- Publish `create-antd-admin@6.0.0` to npm after ensuring GitHub tag **`v6.0.0`** exists on `zuiidea/antd-admin` pointing to a commit that contains the referenced `apps/basic` and `apps/with-lingui` trees (or temporarily document use of `CREATE_ANTD_ADMIN_REF=main` until tags are automated).
- Publish `init-antd-admin@6.0.0` to npm after ensuring GitHub tag **`v6.0.0`** exists on `zuiidea/antd-admin` pointing to a commit that contains the referenced `apps/basic` and `apps/with-lingui` trees (or temporarily document use of `CREATE_ANTD_ADMIN_REF=main` until tags are automated).
---
+13 -3
View File
@@ -10,9 +10,19 @@
"format": "prettier --write \"**/*.{ts,tsx,md}\""
},
"devDependencies": {
"eslint": "^8.57.0",
"prettier": "^3.2.5",
"eslint": "^10.2.1",
"prettier": "^3.8.3",
"turbo": "^2.9.6"
},
"packageManager": "pnpm@8.15.6"
"packageManager": "pnpm@10.32.1",
"pnpm": {
"overrides": {
"vite": "npm:@voidzero-dev/vite-plus-core@latest",
"vitest": "npm:@voidzero-dev/vite-plus-test@latest"
},
"onlyBuiltDependencies": [
"esbuild",
"msw"
]
}
}
+15 -12
View File
@@ -1,4 +1,4 @@
# create-antd-admin
# init-antd-admin
Scaffold a **standalone** [Antd Admin](https://github.com/zuiidea/antd-admin) style app from official monorepo examples (`apps/basic`, `apps/with-lingui`, …). Templates are fetched from GitHub at install time (similar idea to [create-turbo](https://github.com/vercel/turborepo/tree/main/packages/create-turbo)).
@@ -7,19 +7,22 @@ Scaffold a **standalone** [Antd Admin](https://github.com/zuiidea/antd-admin) st
## Usage
```bash
pnpm dlx create-antd-admin@latest
npx init-antd-admin@latest
pnpm dlx init-antd-admin@latest
yarn dlx init-antd-admin@latest
bunx init-antd-admin@latest
```
Non-interactive example:
```bash
pnpm dlx create-antd-admin@latest my-app --example basic -m pnpm
pnpm dlx init-antd-admin@latest my-app --example basic -m pnpm
```
Skip dependency install (faster smoke test):
```bash
pnpm dlx create-antd-admin@latest my-app --example basic --skip-install
pnpm dlx init-antd-admin@latest my-app --example basic --skip-install
```
### CLI options
@@ -30,7 +33,7 @@ pnpm dlx create-antd-admin@latest my-app --example basic --skip-install
| `[project-directory]` | Target folder (relative path or `.`; empty dirs only) |
| `-e, --example <name|url>` | Example id from the built-in list, or a template URL |
| `--example-path <path>` | Extra path inside the repo when using a URL |
| `-m, --package-manager` | `npm` | `yarn` | `pnpm` | `bun` |
| `-m, --package-manager` | `npm` \| `yarn` \| `pnpm` \| `bun` |
| `--skip-install` | Do not run install after scaffold |
| `--skip-transforms` | Skip rewrites (e.g. `package.json` name) |
| `--no-git` | Do not run `git init` |
@@ -38,7 +41,7 @@ pnpm dlx create-antd-admin@latest my-app --example basic --skip-install
| `-h, --help` | Print help |
Run `create-antd-admin --help` for the full help text.
Run `init-antd-admin --help` for the full help text.
## Environment
@@ -69,16 +72,16 @@ Design and implementation notes live under `docs/specs/` in the monorepo (e.g. `
## Publish (maintainers)
CI workflow: `.github/workflows/publish-create-antd-admin.yml` in the monorepo (open on GitHub from the default branch).
CI workflow: `.github/workflows/publish-init-antd-admin.yml` in the monorepo (open on GitHub from the default branch).
1. Add repository secret **`NPM_TOKEN`** (npm automation or granular token with publish permission for `create-antd-admin`).
1. Add repository secret **`NPM_TOKEN`** (npm automation or granular token with publish permission for `init-antd-admin`).
2. Bump **`version`** in this directory’s `package.json`, commit, then either:
- **Actions → “Publish create-antd-admin” → Run workflow**, or
- Push tag **`create-antd-admin-v<version>`** (e.g. `create-antd-admin-v1.0.3` must match `package.json` version `1.0.3`).
- **Actions → “Publish init-antd-admin” → Run workflow**, or
- Push tag **`init-antd-admin-v<version>`** (e.g. `init-antd-admin-v1.0.3` must match `package.json` version `1.0.3`).
3. Push a matching **`v<version>`** tag on the monorepo (e.g. `v1.0.3`) if users rely on the default template ref `v${version}` from the CLI.
If **provenance** publish fails, remove `"provenance": true` from `publishConfig` in `package.json` or adjust token / org policy per [npm provenance docs](https://docs.npmjs.com/generating-provenance-statements).
If **provenance** publish fails from your machine, publish from CI (this workflow passes `--provenance`) or see [npm provenance docs](https://docs.npmjs.com/generating-provenance-statements).
## License
MIT (same as the parent repository).
MIT (same as the parent repository).
+4 -5
View File
@@ -1,11 +1,11 @@
{
"name": "create-antd-admin",
"name": "init-antd-admin",
"version": "1.0.3",
"description": "Create an Antd Admin app from official examples",
"license": "MIT",
"type": "module",
"bin": {
"create-antd-admin": "dist/cli.js"
"init-antd-admin": "dist/cli.js"
},
"files": [
"dist/cli.js",
@@ -20,8 +20,7 @@
"test:watch": "vitest"
},
"publishConfig": {
"access": "public",
"provenance": true
"access": "public"
},
"engines": {
"node": ">=20"
@@ -35,7 +34,7 @@
"devDependencies": {
"@types/node": "^22.10.0",
"tsup": "^8.3.5",
"typescript": "~5.9.3",
"typescript": "~6.0.3",
"vitest": "^3.0.5"
}
}
+1 -1
View File
@@ -17,7 +17,7 @@ const pkg = JSON.parse(
const program = new Command();
program
.name("create-antd-admin")
.name("init-antd-admin")
.description("Create an Antd Admin app from official examples")
.version(pkg.version, "-v, --version")
.argument("[project-directory]", "Directory for the new project")
+1 -1
View File
@@ -7,7 +7,7 @@ import { CliError } from "./errors.js";
export async function downloadExampleToTemp(
gigetSource: string
): Promise<string> {
const dir = path.join(os.tmpdir(), `create-antd-admin-${randomUUID()}`);
const dir = path.join(os.tmpdir(), `init-antd-admin-${randomUUID()}`);
try {
const { dir: outDir } = await downloadTemplate(gigetSource, {
dir,
+1 -1
View File
@@ -15,7 +15,7 @@ export function tryGitInit(root: string, enabled: boolean): boolean {
spawnSync("git", ["add", "-A"], { cwd: root, stdio: "ignore" });
spawnSync(
"git",
["commit", "-m", "chore: initial commit from create-antd-admin"],
["commit", "-m", "chore: initial commit from init-antd-admin"],
{ cwd: root, stdio: "ignore" }
);
return true;
+11 -3
View File
@@ -1,11 +1,16 @@
import { DEFAULT_GITHUB_REPO, ENV_REPO } from "./constants.js";
import { CliError } from "./errors.js";
import { resolveGitRef } from "./resolve-ref.js";
import path from "node:path";
export type ResolvedExampleSource = { gigetSource: string; displayName: string };
function defaultRepo(): string {
return process.env[ENV_REPO]?.trim() || DEFAULT_GITHUB_REPO;
const repo = process.env[ENV_REPO]?.trim() || DEFAULT_GITHUB_REPO;
if (!repo) {
throw new CliError("Default repository is not configured", "INVALID_INPUT");
}
return repo;
}
export function resolveExampleSource(input: {
@@ -16,6 +21,7 @@ export function resolveExampleSource(input: {
if (!example) {
throw new CliError("example is required", "INVALID_INPUT");
}
const { examplePath } = input;
const ref = resolveGitRef();
@@ -27,13 +33,15 @@ export function resolveExampleSource(input: {
if (!example.includes("/")) {
const sub = examplePath
? `apps/${example}/${examplePath}`.replace(/\/+/g, "/")
? path.join("apps", example, examplePath).replace(/\\+/g, "/")
: `apps/${example}`;
const gigetSource = `github:${repo}/${sub}#${ref}`;
return { gigetSource, displayName: example };
}
const pathPart = examplePath ? `${example}/${examplePath}` : example;
const pathPart = examplePath
? path.join(example, examplePath).replace(/\\+/g, "/")
: example;
const gigetSource = `github:${pathPart}#${ref}`;
return { gigetSource, displayName: pathPart };
}
+7454 -6227
View File
File diff suppressed because it is too large Load Diff