first commit

This commit is contained in:
vahidrezvani 2026-02-21 13:03:08 +03:30
commit 16cd65c4da
49 changed files with 13933 additions and 0 deletions

42
.gitignore vendored Normal file
View File

@ -0,0 +1,42 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
.idea
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

3
.prettierrc Normal file
View File

@ -0,0 +1,3 @@
{
"plugins": ["prettier-plugin-tailwindcss"]
}

31
Dockerfile Normal file
View File

@ -0,0 +1,31 @@
# ---------- 1?? Dependencies ----------
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci
# ---------- 2?? Build ----------
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
# ---------- 3?? Production ----------
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
# ??? ???????? ????
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./package.json
EXPOSE 3000
CMD ["npm", "start"]

36
README.md Normal file
View File

@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

13
docker-compose.yml Normal file
View File

@ -0,0 +1,13 @@
networks:
houshan-network:
services:
web:
build: .
container_name: nextjs_app
env_file:
- .env
networks:
- houshan-network
restart: unless-stopped

16
eslint.config.mjs Normal file
View File

@ -0,0 +1,16 @@
import { dirname } from "path";
import { fileURLToPath } from "url";
import { FlatCompat } from "@eslint/eslintrc";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
});
const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"),
];
export default eslintConfig;

16
next.config.ts Normal file
View File

@ -0,0 +1,16 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
images: {
remotePatterns: [
{
protocol: "https",
hostname: "api.houshan.ai",
pathname: "**",
},
],
},
};
export default nextConfig;

7289
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

38
package.json Normal file
View File

@ -0,0 +1,38 @@
{
"name": "houshan-panel",
"version": "0.0.1",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@ant-design/nextjs-registry": "^1.0.2",
"@tanstack/react-query": "^5.64.2",
"antd": "^5.23.0",
"apexcharts": "^4.3.0",
"axios": "^1.7.9",
"iconsax-react": "^0.0.8",
"next": "15.1.4",
"react": "^19.0.0",
"react-apexcharts": "^1.7.0",
"react-dom": "^19.0.0",
"tailwind-merge": "^3.0.1"
},
"devDependencies": {
"@ant-design/v5-patch-for-react-19": "^1.0.3",
"@eslint/eslintrc": "^3",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "15.1.4",
"postcss": "^8",
"prettier": "^3.4.2",
"prettier-plugin-tailwindcss": "^0.6.9",
"tailwindcss": "^3.4.1",
"typescript": "^5"
}
}

8
postcss.config.mjs Normal file
View File

@ -0,0 +1,8 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
},
};
export default config;

View File

@ -0,0 +1,10 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9 11V17L11 15" stroke="#6f8192" stroke-width="1.5" stroke-linecap="round"
stroke-linejoin="round" />
<path d="M9 17L7 15" stroke="#6f8192" stroke-width="1.5" stroke-linecap="round"
stroke-linejoin="round" />
<path d="M22 10V15C22 20 20 22 15 22H9C4 22 2 20 2 15V9C2 4 4 2 9 2H14" stroke="#6f8192"
stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
<path d="M22 10H18C15 10 14 9 14 6V2L22 10Z" stroke="#6f8192" stroke-width="1.5"
stroke-linecap="round" stroke-linejoin="round" />
</svg>

After

Width:  |  Height:  |  Size: 657 B

BIN
public/houshan-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
public/owl.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

View File

@ -0,0 +1,309 @@
"use client";
import type { AssistantDetail } from "@/types/assistant";
import Link from "next/link";
import { useState } from "react";
import { API_ROUTES, request } from "@/services/api";
import { useMutation, useQuery } from "@tanstack/react-query";
import { useParams, useRouter } from "next/navigation";
import TextArea from "antd/es/input/TextArea";
import {
Avatar,
Breadcrumb,
Button,
GetProp,
Image,
Input,
InputNumber,
Modal,
Spin,
Switch,
Upload,
UploadProps,
Select, // <--- اضافه شده
message as antdMessage,
} from "antd";
import TokenService from "@/services/token";
type FileType = Parameters<GetProp<UploadProps, "beforeUpload">>[0];
const getBase64 = (img: FileType, callback: (url: string) => void) => {
const reader = new FileReader();
reader.addEventListener("load", () => callback(reader.result as string));
reader.readAsDataURL(img);
};
export default function Page() {
const { id } = useParams();
const router = useRouter();
const [open, setOpen] = useState(false);
const [message, setMessage] = useState("");
const [loading, setLoading] = useState(false);
const [imageUrl, setImageUrl] = useState<string>();
const [cost, setCost] = useState<number | null>(null);
const [allowedLevels, setAllowedLevels] = useState<number[]>([1]);
const [messageApi, contextHolder] = antdMessage.useMessage();
const fetchData = async () => {
const res = await request.get(API_ROUTES.ASSISTANT.ONE(id as string));
const assistant = res.data.assistant as AssistantDetail;
setCost(res.data.assistant.cost);
setAllowedLevels(res.data.assistant.allowed_levels ?? [1]);
if (assistant.image)
setImageUrl(process.env.NEXT_PUBLIC_API_BASE_URL + assistant.image);
return assistant;
};
const { data, isLoading } = useQuery({
queryKey: ["assistant", id],
queryFn: fetchData,
});
const confirmBot = async () => {
const res = await request.put(API_ROUTES.ASSISTANT.CONFIRM(id as string), {
cost: cost ?? 0,
allowed_levels: allowedLevels,
message,
});
return res.data;
};
const rejectBot = async () => {
const res = await request.put(API_ROUTES.ASSISTANT.REJECT(id as string), {
message,
});
return res.data;
};
const confirmMutation = useMutation({
mutationFn: confirmBot,
onSuccess: async (data) => {
await messageApi.success(data.detail, 1);
router.push("/assistant");
},
});
const rejectMutation = useMutation({
mutationFn: rejectBot,
onSuccess: async (data) => {
await messageApi.success(data.detail, 1);
router.push("/assistant");
},
});
const handleImageChange: UploadProps["onChange"] = (info) => {
if (info.file.status === "uploading") {
setLoading(true);
return;
}
if (info.file.status === "done") {
// Get this url from response in real world.
getBase64(info.file.originFileObj as FileType, (url) => {
setLoading(false);
setImageUrl(url);
});
}
};
if (isLoading)
return (
<div className="flex w-full justify-center">
<Spin />
</div>
);
return (
<>
{contextHolder}
<Breadcrumb
items={[
{ title: <Link href="/assistant">دستیارها</Link> },
{ title: data?.name },
]}
/>
<div className="mt-6 flex flex-col gap-6">
<div className="self-center">
<Upload
name="file"
method="PUT"
listType="picture-circle"
showUploadList={false}
onChange={handleImageChange}
headers={{ Authorization: `Bearer ${TokenService.get()}` }}
action={`${process.env.NEXT_PUBLIC_API_BASE_URL}/admin/bot/assistant/${id}/image`}
>
{imageUrl ? (
<Image alt="avatar" src={imageUrl} preview={false} />
) : (
<div>{loading ? "در حال بارگذاری" : "بارگذاری"}</div>
)}
</Upload>
</div>
<div
className="flex w-fit cursor-pointer items-center gap-1"
onClick={() => {
router.push(`/user/${data?.user.username}`);
}}
>
<Avatar
shape="square"
style={{ background: "#f56a00" }}
src={
process.env.NEXT_PUBLIC_AVATAR_BASE_URL +
`?seed=${data?.user.username}`
}
/>
<div className="text-sm">{data?.user.username}</div>
</div>
<div className="flex flex-col gap-1">
<div className="text-sm">نام دستیار</div>
<Input variant="filled" disabled value={data?.name} />
</div>
<div className="flex flex-col gap-1">
<div className="text-sm">هزینه (بر حسب سکه)</div>
<InputNumber
min={0}
max={100}
variant="outlined"
value={cost}
onChange={setCost}
className="!w-full"
/>
</div>
{/* فیلد جدید: تعیین سطح دستیار */}
<div className="flex flex-col gap-1">
<div className="text-sm">سطوح کاربری مجاز</div>
<Select
mode="multiple"
allowClear
placeholder="سطوح دسترسی را انتخاب کنید"
value={allowedLevels}
onChange={(value) => setAllowedLevels(value)}
options={[
{ label: "بالای 30 سال", value: 1 },
{ label: "بین 18 تا 30 سال", value: 2 },
{ label: "زیر 18 سال", value: 3 },
]}
className="!w-full"
/>
</div>
<div className="flex flex-col gap-1">
<div className="text-sm">توضیحات</div>
<TextArea
variant="filled"
disabled
rows={4}
value={data?.description}
/>
</div>
<div className="flex flex-col gap-1">
<div className="text-sm">پرامپت</div>
<TextArea variant="filled" disabled rows={4} value={data?.prompt} />
</div>
{data && data.links && data.links.length > 0 && (
<div className="flex flex-col gap-1">
<div className="text-sm">لینکها</div>
{data.links.map((link) => {
return (
<Link
key={link}
href={link}
target="_blank"
style={{ direction: "ltr" }}
className="break-words rounded-md border border-[#d9d9d9] px-[11px] py-1 text-sm"
>
{link}
</Link>
);
})}
</div>
)}
{data && data.docs && data.docs.length > 0 && (
<div className="flex flex-col gap-1">
<div className="text-sm">فایلها</div>
{data.docs.map((doc) => {
return (
<Link
key={doc}
style={{ direction: "ltr" }}
href={process.env.NEXT_PUBLIC_API_BASE_URL + doc}
className="rounded-md border border-[#d9d9d9] px-[11px] py-1 text-sm"
>
{doc.split("/").at(-1)}
</Link>
);
})}
</div>
)}
<div className="flex items-center gap-2">
<div className="text-sm">انتشار عمومی</div>
<Switch disabled checked={data?.public} />
</div>
<div className="flex justify-self-end">
<Button
color="yellow"
variant="solid"
onClick={() => setOpen(true)}
disabled={confirmMutation.isPending || rejectMutation.isPending}
>
ویرایش وضعیت
</Button>
</div>
</div>
<Modal
title="ویرایش وضعیت دستیار"
open={open}
onCancel={() => setOpen(false)}
footer={[
data?.status !== "confirmed" && (
<Button
color="cyan"
variant="solid"
onClick={() => confirmMutation.mutate()}
disabled={confirmMutation.isPending || rejectMutation.isPending}
>
انتشار دستیار
</Button>
),
data?.status !== "rejected" && (
<Button
color="danger"
variant="solid"
onClick={() => rejectMutation.mutate()}
disabled={confirmMutation.isPending || rejectMutation.isPending}
>
رد دستیار
</Button>
),
]}
>
<TextArea
variant="outlined"
rows={4}
placeholder="یادداشتی برای کاربر"
value={message}
onChange={(e) => setMessage(e.target.value)}
/>
</Modal>
</>
);
}

230
src/app/assistant/page.tsx Normal file
View File

@ -0,0 +1,230 @@
"use client";
import type { Category } from "@/types/category";
import { Assistant, AssistantStatus } from "@/types/assistant";
import {
Badge,
Input,
Select,
Tabs,
Button,
Modal,
message as antdMessage,
} from "antd";
// فقط IMPORT کنید (اکسپورت ممنوع)
import { Assistants } from "@/components/assistant";
import { Clock, CloseCircle, TickCircle } from "iconsax-react";
import { useCallback, useMemo, useState } from "react";
import { API_ROUTES, request } from "@/services/api";
import { useMutation, useQuery } from "@tanstack/react-query";
import { useRouter, useSearchParams } from "next/navigation";
import TextArea from "antd/es/input/TextArea";
// تنها تابعی که اکسپورت دیفالت می‌شود اینجاست
export default function Page() {
const router = useRouter();
const searchParams = useSearchParams();
const [total, setTotal] = useState(0);
const [category, setCategory] = useState<number>();
const [selected, setSelected] = useState<Assistant[]>([]);
const [q, setQuery] = useState("");
const [page, setPage] = useState(+(searchParams.get("page") ?? 1));
const [activeKey, setActiveKey] = useState(searchParams.get("tab") ?? "1");
const [open, setOpen] = useState(false);
const [message, setMessage] = useState("");
const [messageApi, contextHolder] = antdMessage.useMessage();
const createQueryString = useCallback(
(name: string, value: string) => {
const params = new URLSearchParams(searchParams.toString());
params.set(name, value);
if (name === "tab") params.set("page", "1");
router.push(`?${params.toString()}`, { scroll: false });
},
[router, searchParams],
);
const fetchData = async () => {
const res = await request.get(API_ROUTES.ASSISTANT.ALL, {
q,
page,
category,
status: AssistantStatus[+activeKey - 1],
});
setTotal(res.data.total_count);
return res.data.assistants;
};
const fetchCategories = async () => {
const res = await request.get(API_ROUTES.CATEGORY.ALL);
const categories = res.data.categories as Category[];
return categories.map((category) => ({
label: category.name,
value: category.id,
}));
};
const { data, isLoading, refetch } = useQuery({
queryKey: ["assistants", q, page, category, activeKey],
queryFn: fetchData,
});
const { data: categories } = useQuery({
queryKey: ["categories"],
queryFn: fetchCategories,
});
const rejectMutation = useMutation({
mutationFn: async () => {
setOpen(false);
setMessage("");
for (const assistant of selected) {
await request.put(API_ROUTES.ASSISTANT.REJECT(assistant.id.toString()), {
message,
});
}
return { detail: "انجام شد" };
},
onSuccess: async () => {
await messageApi.success("دستیارها رد شدند");
refetch();
},
});
const deleteMutation = useMutation({
mutationFn: async (id: number) => {
return await request.delete(`/admin/bot/assistant/${id}`);
},
onSuccess: () => {
messageApi.success("دستیار با موفقیت حذف شد");
refetch();
},
onError: () => {
messageApi.error("خطا در حذف دستیار");
},
});
const List = useMemo(() => {
return function List() {
return (
<Assistants
onDelete={(id) => deleteMutation.mutate(id)}
isDeleting={deleteMutation.isPending}
selectionConfig={
activeKey === "2"
? {
setSelectedItems: setSelected,
initialSelecteds: selected,
}
: null
}
page={page}
total={total}
setPage={(p) => {
setPage(p);
createQueryString("page", p.toString());
}}
loading={isLoading}
data={data}
/>
);
};
}, [page, total, isLoading, data, selected, activeKey, deleteMutation, createQueryString]);
return (
<Tabs
activeKey={activeKey}
onChange={(tab) => {
setPage(1);
setActiveKey(tab);
createQueryString("tab", tab);
}}
items={[
{
key: "1",
label: "تایید شده",
icon: <TickCircle className="inline stroke-current" size="20" />,
children: <List />,
},
{
key: "2",
label: (
<div className="inline-flex items-center gap-3">
<div>در انتظار تایید</div>
<Badge className="!text-inherit" count={12} overflowCount={10} />
</div>
),
icon: <Clock className="inline stroke-current" size="20" />,
children: (
<div>
{contextHolder}
<List />
<Modal
title="رد دستیارها"
open={open}
onCancel={() => setOpen(false)}
footer={[
<Button
key="reject"
color="danger"
variant="solid"
onClick={() => rejectMutation.mutate()}
disabled={rejectMutation.isPending}
>
رد دستیارها
</Button>,
]}
>
<TextArea
variant="outlined"
rows={4}
placeholder="یادداشتی برای کاربران"
value={message}
onChange={(e) => setMessage(e.target.value)}
/>
</Modal>
</div>
),
},
{
key: "3",
label: "رد شده",
icon: <CloseCircle className="inline stroke-current" size="20" />,
children: <List />,
},
]}
tabBarExtraContent={{
right: (
<div className="flex items-center gap-2">
{activeKey === "2" && (
<Button
color="red"
variant="solid"
onClick={() => setOpen(true)}
disabled={rejectMutation.isPending || selected.length === 0}
>
رد دستیارها
</Button>
)}
<Input.Search
rootClassName="!w-36 sm:!w-48"
placeholder="جستجو"
allowClear
onSearch={setQuery}
/>
<Select
className="w-28 sm:w-32"
allowClear
placeholder="دسته‌بندی"
value={category}
options={categories}
onChange={setCategory}
/>
</div>
),
}}
/>
);
}

BIN
src/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

3
src/app/forum/page.tsx Normal file
View File

@ -0,0 +1,3 @@
export default function Page() {
return <div></div>;
}

32
src/app/globals.css Normal file
View File

@ -0,0 +1,32 @@
@import url("https://cdn.jsdelivr.net/gh/rastikerdar/vazirmatn@33.003/misc/Farsi-Digits/Vazirmatn-FD-font-face.css");
@tailwind base;
@tailwind components;
@tailwind utilities;
* {
font-family: "Vazirmatn FD", serif !important;
}
.ltr {
direction: ltr !important;
}
.ant-btn-icon {
font-size: 0;
}
.apexcharts-menu,
.apexcharts-legend-series {
direction: rtl;
}
.apexcharts-menu {
left: 8px !important;
right: unset !important;
}
.apexcharts-toolbar {
top: -61px !important;
left: 3px !important;
right: unset !important;
}

43
src/app/layout.tsx Normal file
View File

@ -0,0 +1,43 @@
import type { Metadata } from "next";
import "./globals.css";
import "@ant-design/v5-patch-for-react-19";
import { ConfigProvider } from "antd";
import { Header } from "@/components/header";
import { Container } from "@/components/container";
import { AntdRegistry } from "@ant-design/nextjs-registry";
import { EmptyState } from "@/components/empty";
import ReactQueryClientProvider from "@/services/api/provider";
import { Suspense } from "react";
export const metadata: Metadata = {
title: "پنل مدیریت هوشان",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="fa" dir="rtl">
<body>
<AntdRegistry>
<ConfigProvider direction="rtl" renderEmpty={EmptyState}>
<ReactQueryClientProvider>
<Header />
<main className="py-6">
<Container>
<Suspense fallback={<div>در حال بارگیری</div>}>
{children}
</Suspense>
</Container>
</main>
</ReactQueryClientProvider>
</ConfigProvider>
</AntdRegistry>
</body>
</html>
);
}

9
src/app/page.tsx Normal file
View File

@ -0,0 +1,9 @@
import Image from "next/image";
export default function Page() {
return (
<div className="flex justify-center py-20">
<Image width={360} height={600} src="/owl.webp" alt="هوشان" />
</div>
);
}

211
src/app/report/page.tsx Normal file
View File

@ -0,0 +1,211 @@
"use client";
import { Chart } from "@/components/chart";
import { Card } from "antd";
export default function Page() {
const cardProps = {
className: "w-full",
classNames: { body: "!px-3" },
};
return (
<div className="flex flex-col gap-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<Card {...cardProps} title="تفکیک استفاده کاربران">
<Chart
type="area"
height={240}
series={[
{
name: "سکه رایگان",
data: [100, 82],
},
{
name: "سکه پولی",
data: [11, 32],
},
]}
options={{
dataLabels: {
enabled: false,
},
stroke: {
curve: "smooth",
},
xaxis: {
categories: ["10 دی", "11 دی"],
},
yaxis: {
title: {
text: "سکه‌های مصرفی",
rotate: 90,
},
},
legend: {
show: true,
position: "bottom",
},
tooltip: {
enabled: false,
},
}}
/>
</Card>
<Card {...cardProps} title="وضعیت کاربران">
<Chart
type="radialBar"
height={240}
series={[60, 10]}
options={{
plotOptions: {
radialBar: {
endAngle: 270,
dataLabels: {
name: {
fontSize: "14px",
},
},
},
},
legend: {
show: true,
position: "bottom",
},
labels: ["کاربران فعال", "کاربران جدید"],
}}
/>
</Card>
<Card {...cardProps} title="تعداد درخواست‌ها">
<Chart
type="line"
height={240}
series={[
{
name: "درخواست‌ها",
data: [100, 82, 10, 60, 25, 11, 91],
},
]}
options={{
dataLabels: {
enabled: false,
},
stroke: {
curve: "smooth",
},
xaxis: {
categories: [
"10 دی",
"دی 11",
"دی 12",
"دی 13",
"دی 14",
"دی 15",
"دی 16",
],
},
yaxis: {
title: {
text: "درخواست‌ها",
rotate: 90,
},
},
legend: {
show: true,
position: "bottom",
},
tooltip: {
enabled: false,
},
}}
/>
</Card>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<Card {...cardProps} title="گزارش مصرف مدل‌های زبانی">
<Chart
type="bar"
height={320}
series={[
{
name: "پیام‌ها",
data: [1, 22, 10, 28, 16, 21, 13],
},
]}
options={{
plotOptions: {
bar: {
columnWidth: "75%",
distributed: false,
},
},
tooltip: {
enabled: false,
},
xaxis: {
categories: [
"gpt-4o",
"gpt-4o-mini",
"o1",
"o1-mini",
"gemini-1.5-flash",
"gemini-1.5-pro",
"claude-3.5-sonnet",
],
},
yaxis: {
title: {
text: "پیام‌ها",
rotate: 90,
},
},
}}
/>
</Card>
<Card {...cardProps} title="گزارش مصرف دستیارها">
<Chart
type="bar"
height={320}
series={[
{
name: "پیام‌ها",
data: [1, 22, 10, 28, 16, 21, 13],
},
]}
options={{
plotOptions: {
bar: {
columnWidth: "75%",
distributed: false,
},
},
tooltip: {
enabled: false,
},
xaxis: {
categories: [
"gpt-4o",
"gpt-4o-mini",
"o1",
"o1-mini",
"gemini-1.5-flash",
"gemini-1.5-pro",
"claude-3.5-sonnet",
],
},
yaxis: {
title: {
text: "پیام‌ها",
rotate: 90,
},
},
}}
/>
</Card>
</div>
</div>
);
}

View File

@ -0,0 +1,96 @@
"use client";
import { formatPersianDate } from "@/helpers/utils";
import { Table, Tag } from "antd";
import { useState } from "react";
const { Column } = Table;
interface DataType {
key: React.Key;
name: string;
mobile_number: string;
income: number;
created_at: Date;
confirmed: boolean;
}
const data: DataType[] = [
{
key: "1",
name: "غلامی",
mobile_number: "09139000000",
income: 4200,
created_at: new Date(),
confirmed: true,
},
{
key: "2",
name: "غلامی",
mobile_number: "09139000000",
income: 4200,
created_at: new Date(),
confirmed: true,
},
{
key: "3",
name: "غلامی",
mobile_number: "09139000000",
income: 4200,
created_at: new Date(),
confirmed: false,
},
];
export default function Page() {
const [total] = useState(1);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
return (
<Table<DataType>
dataSource={data}
loading={loading}
pagination={{
position: ["bottomCenter"],
pageSize: 10,
total,
current: page,
disabled: loading,
showSizeChanger: false,
onChange: (page) => {
setLoading(true);
setPage(page);
},
}}
>
<Column title="نام" dataIndex="name" key="name" />
<Column
title="شماره موبایل"
dataIndex="mobile_number"
key="mobile_number"
/>
<Column title="درآمد (سکه)" dataIndex="income" key="income" />
<Column
title="تاریخ ایجاد"
dataIndex="created_at"
key="created_at"
render={(date: Date) => formatPersianDate(date)}
/>
<Column
title="وضعیت"
dataIndex="confirmed"
key="confirmed"
render={(status: boolean) => (
<Tag color={status ? "success" : "warning"}>
{status ? "تسویه" : "در انتظار"}
</Tag>
)}
/>
</Table>
);
}

View File

@ -0,0 +1,143 @@
"use client";
import type { TicketHistory } from "@/types/ticket";
import { API_ROUTES, request } from "@/services/api";
import { useMutation, useQuery } from "@tanstack/react-query";
import { useParams } from "next/navigation";
import { Button, Spin, message as antdMessage } from "antd";
import { twMerge } from "tailwind-merge";
import { detectDir, formatPersianDate } from "@/helpers/utils";
import { Send } from "iconsax-react";
import { Container } from "@/components/container";
import TextArea from "antd/es/input/TextArea";
import { useState } from "react";
import { FilePreview } from "@/components/file";
export default function Page() {
const { id } = useParams();
const [message, setMessage] = useState("");
const [messageApi, contextHolder] = antdMessage.useMessage();
const submitMessage = async () => {
const formData = new FormData();
formData.append("text", message);
formData.append("user_id", id as string);
const res = await request.post(API_ROUTES.TICKET.ALL, formData, {
headers: {
"Content-Type": "multipart/form-data",
},
});
return res.data;
};
const fetchData = async () => {
const res = await request.get(API_ROUTES.TICKET.ONE(id as string));
return res.data as TicketHistory;
};
const { data, isLoading, refetch } = useQuery({
queryKey: ["ticket", id],
queryFn: fetchData,
});
const submitMessageMutation = useMutation({
mutationFn: submitMessage,
onSuccess: async () => {
messageApi.success("پیام با موفقیت ارسال شد");
setMessage("");
await refetch();
},
});
if (isLoading)
return (
<div className="flex w-full justify-center">
<Spin />
</div>
);
return (
<>
{contextHolder}
<div className="pb-14">
<div className="flex flex-col gap-4">
{data &&
Object.keys(data).map((key) => {
return (
<div key={key} className="flex flex-col gap-2">
<div className="self-center rounded-lg bg-sky-100 px-2 py-1 text-sm">
{key}
</div>
{data[key].map((message) => {
return (
<div
key={message.id}
className={twMerge(
"flex max-w-[92%] flex-col items-end gap-0.5",
message.role === "user" ? "self-end" : "self-start",
)}
>
<div
className={twMerge(
"overflow-hidden rounded-lg border",
message.role === "user"
? "border-rose-100 bg-rose-100"
: "border-indigo-100 bg-indigo-100",
)}
>
{message.file && <FilePreview url={message.file} />}
{message.text && (
<div
className="min-w-12 px-2 py-1"
dir={detectDir(message.text)}
>
{message.text}
</div>
)}
</div>
<div className="text-xs">
{formatPersianDate(
new Date(message.created_at),
false,
)}
</div>
</div>
);
})}
</div>
);
})}
</div>
</div>
<div className="fixed bottom-0 right-0 w-full">
<Container>
<div className="flex items-center gap-2 rounded-t-lg bg-slate-100 p-3">
<Button
color="primary"
variant="text"
icon={<Send size="24" className="stroke-current" />}
onClick={() => submitMessageMutation.mutate()}
disabled={submitMessageMutation.isPending || !message}
/>
<TextArea
autoSize
variant="borderless"
value={message}
onChange={(e) => {
setMessage(e.target.value);
}}
/>
</div>
</Container>
</div>
</>
);
}

80
src/app/ticket/page.tsx Normal file
View File

@ -0,0 +1,80 @@
"use client";
import { formatPersianDate } from "@/helpers/utils";
import { API_ROUTES, request } from "@/services/api";
import { Ticket } from "@/types/ticket";
import { useQuery } from "@tanstack/react-query";
import { Avatar, Badge, List } from "antd";
import { useRouter } from "next/navigation";
import { useState } from "react";
export default function Page() {
const router = useRouter();
const [page, setPage] = useState(1);
const [total, setTotal] = useState(1);
const fetchData = async () => {
const res = await request.get(API_ROUTES.TICKET.ALL, {
page,
});
setTotal(res.data.total_count);
return res.data.users as Ticket[];
};
const { data, isLoading } = useQuery({
queryKey: ["tickets", page],
queryFn: fetchData,
});
return (
<List
loading={isLoading}
dataSource={data}
pagination={{
position: "bottom",
align: "center",
pageSize: 10,
total,
current: page,
disabled: isLoading,
showSizeChanger: false,
onChange: (page) => {
setPage(page);
},
}}
renderItem={(item) => (
<List.Item
className="cursor-pointer"
onClick={() => router.push(`/ticket/${item.id}`)}
actions={[
<div key="list-date" className="flex items-center gap-1.5">
{item.last_ticket.role === "user" && (
<Badge key="list-date" dot />
)}
<div>
{formatPersianDate(new Date(item.last_ticket.created_at))}
</div>
</div>,
]}
>
<List.Item.Meta
avatar={
<Avatar
src={
item.image
? process.env.NEXT_PUBLIC_API_BASE_URL + item.image
: process.env.NEXT_PUBLIC_AVATAR_BASE_URL +
`?seed=${item.username}`
}
/>
}
title={item.username}
description={item.last_ticket.text || "فایل"}
/>
</List.Item>
)}
/>
);
}

View File

@ -0,0 +1,183 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useQuery } from "@tanstack/react-query";
import { Table, Tag, Input, Select, Card, Button } from "antd";
import type { ColumnsType } from "antd/es/table";
import { Receipt, SearchNormal1 } from "iconsax-react";
import { API_ROUTES, request } from "@/services/api";
import { Container } from "@/components/container";
import type { Transaction } from "@/types/transaction";
const { Option } = Select;
export default function TransactionsPage() {
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
// filters
const [search, setSearch] = useState("");
const [typeFilter, setTypeFilter] = useState<string | undefined>(undefined);
const fetchData = async () => {
const res = await request.get(API_ROUTES.TRANSACTION.ALL, {
page,
limit: 10,
q: search,
type: typeFilter,
});
setTotal(res.data.total_count || 0);
return res.data.transactions as Transaction[];
};
const { data, isLoading } = useQuery({
queryKey: ["transactions", page, search, typeFilter],
queryFn: fetchData,
});
const columns: ColumnsType<Transaction> = [
{
title: "کاربر",
dataIndex: "user",
key: "user",
render: (user) =>
user ? (
<Link
href={`/user/${user.username}`}
className="text-blue-600 font-medium hover:underline"
>
{user.username}
</Link>
) : (
<span className="text-gray-400">سیستمی / حذف شده</span>
),
},
{
title: "مقدار",
dataIndex: "amount",
key: "amount",
render: (amount: number) => (
<span className="font-bold">{amount.toLocaleString()}</span>
),
},
{
title: "نوع",
dataIndex: "type",
key: "type",
render: (type: string) => {
let color: "default" | "green" | "blue" | "gold" = "default";
let text = type;
switch (type) {
case "gift":
color = "green";
text = "هدیه";
break;
case "credit":
color = "blue";
text = "اعتبار";
break;
case "free":
color = "gold";
text = "رایگان";
break;
}
return <Tag color={color}>{text}</Tag>;
},
},
{
title: "توضیحات",
dataIndex: "description",
key: "description",
ellipsis: true,
},
{
title: "تاریخ",
dataIndex: "created_at",
key: "created_at",
render: (date: string) => (
<span dir="ltr" className="text-sm text-gray-500">
{new Date(date).toLocaleDateString("fa-IR", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
})}
</span>
),
},
];
return (
<Container>
<div className="flex flex-col gap-6 py-8">
{/* Header */}
<div className="flex items-center gap-2">
<Receipt size={28} className="text-indigo-600" />
<h1 className="text-xl font-bold">لیست کلی تراکنشها</h1>
</div>
{/* Filters */}
<Card size="small" className="shadow-sm">
<div className="flex flex-col md:flex-row gap-4">
<Input
prefix={<SearchNormal1 size={18} className="text-gray-400" />}
placeholder="جستجو (نام کاربری، توضیحات...)"
className="md:w-1/3"
allowClear
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<Select
placeholder="نوع تراکنش"
allowClear
className="md:w-48"
value={typeFilter}
onChange={(value) => setTypeFilter(value)}
>
<Option value="gift">هدیه (Gift)</Option>
<Option value="credit">اعتبار (Credit)</Option>
<Option value="free">رایگان (Free)</Option>
</Select>
{(search || typeFilter) && (
<Button
type="text"
danger
onClick={() => {
setSearch("");
setTypeFilter(undefined);
setPage(1);
}}
>
حذف فیلترها
</Button>
)}
</div>
</Card>
{/* Table */}
<Table
columns={columns}
dataSource={data}
rowKey="id"
loading={isLoading}
pagination={{
current: page,
pageSize: 10,
total,
onChange: setPage,
showSizeChanger: false,
position: ["bottomCenter"],
}}
/>
</div>
</Container>
);
}

View File

@ -0,0 +1,140 @@
"use client";
import { useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { Table, Tag, Button, Card } from "antd";
import type { ColumnsType } from "antd/es/table";
import { ArrowRight2, EmptyWallet } from "iconsax-react";
import { API_ROUTES, request } from "@/services/api";
import { Container } from "@/components/container";
import type { Transaction } from "@/types/transaction";
export default function UserHistoryPage() {
const router = useRouter();
const params = useParams();
const userId = params.id as string;
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const fetchData = async () => {
const res = await request.get(API_ROUTES.USER.HISTORY(userId), {
page,
limit: 10,
});
setTotal(res.data.total_count || 0);
return res.data.transactions as Transaction[];
};
const { data, isLoading } = useQuery({
queryKey: ["user-history", userId, page],
queryFn: fetchData,
});
const columns: ColumnsType<Transaction> = [
{
title: "تاریخ",
dataIndex: "created_at",
key: "created_at",
render: (date: string) => (
<span dir="ltr">
{new Date(date).toLocaleDateString("fa-IR", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
})}
</span>
),
},
{
title: "نوع",
dataIndex: "type",
key: "type",
render: (type: string) => {
let color: "default" | "green" | "blue" | "gold" = "default";
let text = type;
switch (type) {
case "gift":
color = "green";
text = "هدیه";
break;
case "credit":
color = "blue";
text = "اعتبار";
break;
case "free":
color = "gold";
text = "رایگان";
break;
}
return <Tag color={color}>{text}</Tag>;
},
},
{
title: "مقدار",
dataIndex: "amount",
key: "amount",
render: (amount: number) => (
<span className="font-bold text-lg">
{amount.toLocaleString()}{" "}
<span className="text-xs font-normal text-gray-500">امتیاز</span>
</span>
),
},
{
title: "توضیحات",
dataIndex: "description",
key: "description",
ellipsis: true,
},
];
return (
<Container>
<div className="flex flex-col gap-6 py-8">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Button
type="text"
icon={<ArrowRight2 size={20} />}
onClick={() => router.back()}
/>
<h1 className="text-xl font-bold flex items-center gap-2">
<EmptyWallet size={24} className="text-blue-600" />
تاریخچه تراکنشهای کاربر
</h1>
</div>
</div>
{/* Table */}
<Card className="shadow-sm">
<Table
columns={columns}
dataSource={data}
rowKey="id"
loading={isLoading}
pagination={{
current: page,
pageSize: 10,
total,
onChange: setPage,
position: ["bottomCenter"],
showSizeChanger: false,
}}
locale={{
emptyText: "هیچ تراکنشی یافت نشد",
}}
/>
</Card>
</div>
</Container>
);
}

294
src/app/user/[id]/page.tsx Normal file
View File

@ -0,0 +1,294 @@
"use client";
import Link from "next/link";
import { useParams } from "next/navigation"; // useRouter حذف شد
import { useState, useEffect } from "react";
import { useQuery, useMutation } from "@tanstack/react-query";
import { API_ROUTES, request } from "@/services/api";
import {
Avatar,
Breadcrumb,
Button,
Card,
Descriptions,
Divider,
message as antdMessage,
Select,
Spin,
Table,
Tabs,
Tag,
Typography,
} from "antd";
import type { ColumnsType } from "antd/es/table";
import { UserOutlined, RobotOutlined, TeamOutlined,HistoryOutlined } from "@ant-design/icons";
// تعریف تایپ‌ها
interface SubUser {
id: string;
username: string;
name: string | null;
mobile_number: string | null;
created_at: string;
level: number;
}
// تعریف تایپ بات برای جلوگیری از خطای any
interface Bot {
id: number;
name: string;
description: string;
image: string | null;
}
interface UserDetail {
id: string;
username: string;
name: string | null;
mobile_number: string | null;
email: string | null;
image: string | null;
level: number;
credit: number;
income: number;
created_at: string;
sub_users: SubUser[];
bots?: Bot[]; // استفاده از تایپ Bot به جای any
}
export default function UserPage() {
const { id } = useParams();
const [messageApi, contextHolder] = antdMessage.useMessage();
const [selectedLevel, setSelectedLevel] = useState<number>(1);
// 1. دریافت اطلاعات کامل کاربر
const { data: user, isLoading, refetch } = useQuery({
queryKey: ["user", id],
queryFn: async () => {
const res = await request.get(API_ROUTES.USER.ONE(id as string));
return res.data.user as UserDetail;
},
});
// 2. سینک کردن لول انتخابی
useEffect(() => {
if (user) {
setSelectedLevel(user.level || 1);
}
}, [user]);
// 3. تابع آپدیت لول
const updateLevelMutation = useMutation({
mutationFn: async () => {
if (!user?.id) throw new Error("User ID not found");
return await request.put(`/admin/user/${user.id}/level`, {
level: selectedLevel,
});
},
onSuccess: () => {
messageApi.success("سطح کاربر با موفقیت بروزرسانی شد");
refetch();
},
onError: () => {
messageApi.error("خطا در تغییر سطح کاربر");
},
});
// ستون‌های جدول زیرمجموعه‌ها
const subUsersColumns: ColumnsType<SubUser> = [
{
title: "نام کاربری",
dataIndex: "username",
key: "username",
render: (text) => (
<Link href={`/user/${text}`} className="text-blue-600 hover:underline">
{text}
</Link>
),
},
{
title: "نام",
dataIndex: "name",
key: "name",
render: (text) => text || <span className="text-gray-400">---</span>,
},
{
title: "شماره موبایل",
dataIndex: "mobile_number",
key: "mobile_number",
render: (text) => <span dir="ltr">{text}</span>,
},
{
title: "سطح",
dataIndex: "level",
key: "level",
render: (level) => (
<Tag color={level > 1 ? "gold" : "blue"}>سطح {level}</Tag>
),
},
{
title: "تاریخ عضویت",
dataIndex: "created_at",
key: "created_at",
render: (date) => new Date(date).toLocaleDateString("fa-IR"),
},
];
if (isLoading)
return (
<div className="flex h-screen w-full items-center justify-center">
<Spin size="large" />
</div>
);
if (!user)
return (
<div className="flex h-screen w-full items-center justify-center text-red-500">
کاربر یافت نشد
</div>
);
const items = [
{
key: "1",
label: (
<span>
<RobotOutlined />
دستیارها
</span>
),
children: (
<div className="p-4">
{user.bots && user.bots.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{user.bots.map((bot: Bot) => (
<Card key={bot.id} title={bot.name} size="small" extra={<Link href={`/assistant/${bot.id}`}>مشاهده</Link>}>
<p className="truncate text-gray-500">{bot.description}</p>
</Card>
))}
</div>
) : (
<p className="text-gray-500 text-center">این کاربر دستیاری ندارد.</p>
)}
</div>
),
},
{
key: "2",
label: (
<span>
<TeamOutlined />
زیرمجموعهها ({user.sub_users ? user.sub_users.length : 0})
</span>
),
children: (
<Table
columns={subUsersColumns}
dataSource={user.sub_users || []}
rowKey="id"
pagination={{ pageSize: 5 }}
locale={{ emptyText: "زیرمجموعه‌ای یافت نشد" }}
/>
),
},
];
return (
<>
{contextHolder}
<Breadcrumb
items={[
{ title: <Link href="/dashboard">داشبورد</Link> },
{ title: <Link href="/user">کاربران</Link> },
{ title: user.username },
]}
className="mb-6"
/>
<div className="flex flex-col gap-6">
<Card bordered={false} className="shadow-sm">
<div className="flex flex-col md:flex-row gap-6">
<div className="flex flex-col items-center gap-4 border-l pl-6 md:w-1/4">
<Avatar
size={100}
src={
user.image
? `${process.env.NEXT_PUBLIC_API_BASE_URL}${user.image}`
: `${process.env.NEXT_PUBLIC_AVATAR_BASE_URL}?seed=${user.username}`
}
icon={<UserOutlined />}
/>
<div className="text-center">
<Typography.Title level={4} className="!mb-0">
{user.name || "کاربر ناشناس"}
</Typography.Title>
<Typography.Text type="secondary" copyable>@{user.username}</Typography.Text>
</div>
<Tag color="purple" className="px-3 py-1 text-sm mt-2">
سطح فعلی: {user.level}
</Tag>
{/* ۲. دکمه جدید برای مشاهده تاریخچه تراکنش‌ها */}
<Link href={`/user/${user.id}/history`} className="w-full">
<Button
block
icon={<HistoryOutlined />}
className="mt-2 text-blue-600 border-blue-200 bg-blue-50"
>
تاریخچه تراکنشها
</Button>
</Link>
</div>
<div className="flex-1">
<Descriptions title="مشخصات فردی" layout="vertical" column={{ xs: 1, sm: 2, md: 3 }}>
<Descriptions.Item label="ایمیل">{user.email || "---"}</Descriptions.Item>
<Descriptions.Item label="شماره موبایل">{user.mobile_number || "---"}</Descriptions.Item>
<Descriptions.Item label="تاریخ ثبت نام">
{new Date(user.created_at).toLocaleDateString("fa-IR")}
</Descriptions.Item>
<Descriptions.Item label="موجودی حساب">
{user.credit.toLocaleString()} سکه
</Descriptions.Item>
<Descriptions.Item label="درآمد">
{user.income.toLocaleString()} تومان
</Descriptions.Item>
</Descriptions>
<Divider />
<div className="flex items-end gap-4 bg-blue-50 p-4 rounded-lg border border-blue-100">
<div className="w-full md:w-1/3">
<label className="block text-xs text-gray-500 mb-1">تعیین سطح جدید</label>
<Select
className="w-full"
value={selectedLevel}
onChange={(val) => setSelectedLevel(val)}
options={[
{ label: "بالای 30 سال", value: 1 },
{ label: "بین 18 تا 30 سال", value: 2 },
{ label: "زیر 18 سال", value: 3 },
]}
/>
</div>
<Button
type="primary"
onClick={() => updateLevelMutation.mutate()}
loading={updateLevelMutation.isPending}
disabled={selectedLevel === user.level}
>
بروزرسانی سطح
</Button>
</div>
</div>
</div>
</Card>
<Card bordered={false} className="shadow-sm">
<Tabs defaultActiveKey="2" items={items} />
</Card>
</div>
</>
);
}

83
src/app/user/page.tsx Normal file
View File

@ -0,0 +1,83 @@
"use client";
import type { User } from "@/types/user";
import { Avatar, Input, List } from "antd";
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { API_ROUTES, request } from "@/services/api";
import { useRouter, useSearchParams } from "next/navigation";
export default function Page() {
const router = useRouter();
const searchParams = useSearchParams();
const [total, setTotal] = useState(0);
const [q, setQuery] = useState("");
const [page, setPage] = useState(+(searchParams.get("page") ?? 1));
const fetchData = async () => {
const res = await request.get(API_ROUTES.USER.ALL, {
q,
page,
});
setTotal(res.data.total_count);
return res.data.users as User[];
};
const { data, isLoading } = useQuery({
queryKey: ["users", q, page],
queryFn: fetchData,
});
return (
<div className="flex flex-col gap-2">
<Input.Search
rootClassName="!w-36 sm:!w-48 self-end"
placeholder="جستجو"
allowClear
onSearch={(value) => {
setQuery(value);
}}
/>
<List
loading={isLoading}
dataSource={data}
pagination={{
position: "bottom",
align: "center",
pageSize: 10,
total,
current: page,
disabled: isLoading,
showSizeChanger: false,
onChange: (page) => {
setPage(page);
},
}}
renderItem={(item) => (
<List.Item
key={item.id}
className="cursor-pointer"
onClick={() => router.push(`/user/${item.username}`)}
>
<List.Item.Meta
avatar={
<Avatar
src={
item.image
? process.env.NEXT_PUBLIC_API_BASE_URL + item.image
: process.env.NEXT_PUBLIC_AVATAR_BASE_URL +
`?seed=${item.username}`
}
/>
}
title={item.username}
description={item.name}
/>
</List.Item>
)}
/>
</div>
);
}

122
src/app/user/score/page.tsx Normal file
View File

@ -0,0 +1,122 @@
"use client";
import { useState } from "react";
import { Button, Card, Form, Input, InputNumber, message } from "antd";
import { Container } from "@/components/container";
import { API_ROUTES, request } from "@/services/api";
import { useRouter } from "next/navigation";
import { ArrowRight2 } from "iconsax-react";
interface GroupScoreFormValues {
users: string;
coins: number;
price: number;
}
export default function GroupScorePage() {
const [loading, setLoading] = useState(false);
const router = useRouter();
const [form] = Form.useForm<GroupScoreFormValues>();
const onFinish = async (values: GroupScoreFormValues) => {
setLoading(true);
try {
const userIds = values.users
.split(/[\n,]+/)
.map((id) => id.trim())
.filter(Boolean);
if (userIds.length === 0) {
message.error("لطفا شناسه کاربران را وارد کنید");
setLoading(false);
return;
}
await request.post(API_ROUTES.USER.SCORE, {
user_ids: userIds,
coins: values.coins,
price: values.price,
});
message.success("امتیازدهی با موفقیت انجام شد");
form.resetFields();
} catch (error) {
console.error(error);
message.error("خطایی در ثبت امتیاز رخ داد");
} finally {
setLoading(false);
}
};
return (
<Container>
<div className="flex flex-col gap-6 py-8">
<div className="flex items-center gap-2">
<Button
type="text"
icon={<ArrowRight2 size={20} />}
onClick={() => router.back()}
/>
<h1 className="text-xl font-bold">امتیازدهی گروهی</h1>
</div>
<Card className="max-w-2xl w-full self-center">
<Form<GroupScoreFormValues>
form={form}
layout="vertical"
onFinish={onFinish}
>
<Form.Item
name="users"
label="شناسه کاربران (User IDs)"
help="شناسه کاربران را با کاما (,) یا خط جدید وارد کنید."
rules={[{ required: true, message: "لطفا کاربران را مشخص کنید" }]}
>
<Input.TextArea
rows={5}
placeholder="مثال:\nuser_123\nuser_456"
className="font-mono"
/>
</Form.Item>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Form.Item
name="coins"
label="تعداد سکه"
rules={[{ required: true, message: "لطفا تعداد سکه را وارد کنید" }]}
>
<InputNumber<number>
className="w-full"
min={1}
placeholder="مثال: 100"
formatter={(value) => `${value}`.replace(/\B(?=(\d{3})+(?!\d))/g, ",")}
parser={(value) => value?.replace(/\$\s?|(,*)/g, "") as unknown as number}
/>
</Form.Item>
<Form.Item
name="price"
label="مبلغ (تومان)"
rules={[{ required: true, message: "لطفا مبلغ را وارد کنید" }]}
>
<InputNumber<number>
className="w-full"
min={0}
placeholder="مثال: 50000"
formatter={(value) => `${value}`.replace(/\B(?=(\d{3})+(?!\d))/g, ",")}
parser={(value) => value?.replace(/\$\s?|(,*)/g, "") as unknown as number}
/>
</Form.Item>
</div>
<Form.Item>
<Button type="primary" htmlType="submit" loading={loading} block>
ثبت امتیاز
</Button>
</Form.Item>
</Form>
</Card>
</div>
</Container>
);
}

View File

@ -0,0 +1,156 @@
"use client";
import React from "react";
import { Table, Button, Space, Popconfirm, Tooltip, Avatar, Tag } from "antd";
import type { ColumnsType } from "antd/es/table";
import { Assistant } from "@/types/assistant";
import { Trash, Eye, User } from "iconsax-react";
import Link from "next/link";
interface SelectionConfig {
setSelectedItems: (items: Assistant[]) => void;
initialSelecteds: Assistant[];
}
interface AssistantsProps {
data: Assistant[];
loading: boolean;
page: number;
total: number;
setPage: (page: number) => void;
selectionConfig?: SelectionConfig | null;
onDelete?: (id: number) => void;
isDeleting?: boolean;
}
// این کامپوننت باید اکسپورت شود تا در فایل پیج استفاده شود
export const Assistants = ({
data,
loading,
page,
total,
setPage,
selectionConfig,
onDelete,
isDeleting,
}: AssistantsProps) => {
const columns: ColumnsType<Assistant> = [
{
title: "شناسه",
dataIndex: "id",
key: "id",
width: 60,
align: "center",
},
{
title: "تصویر",
dataIndex: "image",
key: "image",
width: 70,
align: "center",
render: (img) => (
<Avatar
shape="square"
src={img ? `${process.env.NEXT_PUBLIC_API_BASE_URL}${img}` : undefined}
icon={<User size="20"/>}
/>
)
},
{
title: "نام دستیار",
dataIndex: "name",
key: "name",
},
{
title: "دسته‌بندی",
dataIndex: ["category", "name"],
key: "category",
render: (text) => (
text ? <Tag color="cyan">{text}</Tag> : <span className="text-gray-400">---</span>
),
},
{
title: "سازنده",
dataIndex: ["user", "username"],
key: "creator",
render: (text) => text || "---",
},
{
title: "وضعیت",
dataIndex: "status",
key: "status",
render: (status) => {
const color = status === "confirmed" ? "green" : status === "rejected" ? "red" : "gold";
return <Tag color={color}>{status}</Tag>;
}
},
{
title: "تاریخ",
dataIndex: "created_at",
key: "created_at",
render: (date) => new Date(date).toLocaleDateString("fa-IR"),
},
{
title: "عملیات",
key: "action",
width: 120,
render: (_, record) => (
<Space>
<Link href={`/assistant/${record.id}`}>
<Tooltip title="مشاهده">
<Button icon={<Eye size="18" />} size="small" />
</Tooltip>
</Link>
{onDelete && (
<Popconfirm
title="حذف دستیار"
description="آیا از حذف این دستیار مطمئن هستید؟"
onConfirm={() => onDelete(record.id)}
okText="بله"
cancelText="خیر"
okButtonProps={{ danger: true, loading: isDeleting }}
>
<Tooltip title="حذف">
<Button
type="primary"
danger
icon={<Trash size="16" color="#fff"/>}
size="small"
/>
</Tooltip>
</Popconfirm>
)}
</Space>
),
},
];
return (
<Table
rowKey="id"
columns={columns}
dataSource={data}
loading={loading}
pagination={{
current: page,
total: total,
onChange: setPage,
pageSize: 20,
showSizeChanger: false
}}
rowSelection={
selectionConfig
? {
selectedRowKeys: selectionConfig.initialSelecteds?.map((i) => i.id),
onChange: (_: React.Key[], selectedRows: Assistant[]) => {
selectionConfig.setSelectedItems(selectedRows);
},
}
: undefined
}
scroll={{ x: 900 }}
/>
);
};

110
src/components/chart.tsx Normal file
View File

@ -0,0 +1,110 @@
import type { Props } from "react-apexcharts";
import dynamic from "next/dynamic";
const ReactApexChart = dynamic(() => import("react-apexcharts"), {
ssr: false,
});
export const Chart = ({
series,
options,
height,
type,
width = "100%",
}: Props) => {
return (
<ReactApexChart
type={type}
height={height}
width={width}
series={series}
options={{
legend: {
show: false,
},
colors: ["#1677FF", "#AC1269"],
...options,
chart: {
toolbar: {
show: true,
tools: {
download: "<img src='/document-download.svg' />",
reset: false,
zoom: false,
zoomin: false,
zoomout: false,
pan: false,
},
},
defaultLocale: "fa",
locales: [FaLocale],
...options?.chart,
},
xaxis: {
axisTicks: { show: false },
labels: { rotate: -45 },
...options?.xaxis,
},
grid: {
borderColor: "#f2f2f2",
},
}}
/>
);
};
const FaLocale = {
name: "fa",
options: {
months: [
"فروردین",
"اردیبهشت",
"خرداد",
"تیر",
"مرداد",
"شهریور",
"مهر",
"آبان",
"آذر",
"دی",
"بهمن",
"اسفند",
],
shortMonths: [
"فرو",
"ارد",
"خرد",
"تیر",
"مرد",
"شهر",
"مهر",
"آبا",
"آذر",
"دی",
"بهمـ",
"اسفـ",
],
days: [
"یکشنبه",
"دوشنبه",
"سه شنبه",
"چهارشنبه",
"پنجشنبه",
"جمعه",
"شنبه",
],
shortDays: ["ی", "د", "س", "چ", "پ", "ج", "ش"],
toolbar: {
exportToSVG: "دانلود SVG",
exportToPNG: "دانلود PNG",
exportToCSV: "دانلود CSV",
menu: "منو",
selection: "انتخاب",
selectionZoom: "بزرگنمایی انتخابی",
zoomIn: "بزرگنمایی",
zoomOut: "کوچکنمایی",
pan: "پیمایش",
reset: "بازنشانی بزرگنمایی",
},
},
};

View File

@ -0,0 +1,7 @@
type Props = {
children: React.ReactNode;
};
export const Container = ({ children }: Props) => {
return <div className="container px-4 sm:px-6">{children}</div>;
};

9
src/components/empty.tsx Normal file
View File

@ -0,0 +1,9 @@
"use client";
import { Empty } from "antd";
export const EmptyState = () => {
return (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="موردی یافت نشد" />
);
};

49
src/components/file.tsx Normal file
View File

@ -0,0 +1,49 @@
import { Image } from "antd";
import { DocumentDownload } from "iconsax-react";
import Link from "next/link";
export const FilePreview = ({ url }: { url: string }) => {
// Extract file extension
const getFileExtension = (url: string) => {
return url.split(".").at(-1) as string;
};
const fileExtension = getFileExtension(url);
// Define supported file types
const audioExtensions = ["mp3", "wav", "ogg", "aac", "flac"];
const imageExtensions = ["jpg", "jpeg", "png", "gif", "webp", "bmp", "svg"];
const isAudio = audioExtensions.includes(fileExtension);
const isImage = imageExtensions.includes(fileExtension);
return (
<div className="flex flex-col text-[0]">
{isAudio ? (
<audio controls className="self-center px-2 py-1">
<source
src={process.env.NEXT_PUBLIC_API_BASE_URL + url}
type={`audio/${fileExtension}`}
/>
Your browser does not support the audio element.
</audio>
) : isImage ? (
<Image
alt=""
width={240}
rootClassName="self-center"
src={process.env.NEXT_PUBLIC_API_BASE_URL + url}
/>
) : (
<Link
download
href={process.env.NEXT_PUBLIC_API_BASE_URL + url}
className="m-1 flex items-center gap-2 self-end rounded-lg bg-slate-50 p-1 text-sm"
>
{url.split("/").at(-1)}
<DocumentDownload size="20" className="stroke-current" />
</Link>
)}
</div>
);
};

218
src/components/header.tsx Normal file
View File

@ -0,0 +1,218 @@
"use client";
import { Button, Drawer, Dropdown, Input } from "antd";
import type { MenuProps } from "antd";
import { Container } from "./container";
import { useEffect, useState } from "react";
import {
ArrowRight2,
Chart1,
Coin,
Direct,
EmojiNormal,
HambergerMenu,
Home2,
Profile2User,
User,
Gift,
Receipt,
} from "iconsax-react";
import { usePathname, useRouter } from "next/navigation";
import Image from "next/image";
import TokenService from "@/services/token";
export const Header = () => {
const router = useRouter();
const pathname = usePathname();
const [open, setOpen] = useState(false);
const [loginOpen, setLoginOpen] = useState(false);
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
useEffect(() => {
const u = TokenService.getUser();
if (u) setUsername(u);
}, []);
const isLoggedIn = TokenService.get();
const showDrawer = () => {
setOpen(true);
};
const onClose = () => {
setOpen(false);
};
const items: MenuProps["items"] = [
{
label: isLoggedIn ? (
<div>{username}</div>
) : (
<Input
placeholder={"username"}
onInput={(v) => setUsername((v.target as HTMLInputElement).value)}
/>
),
key: "0",
},
{
label: isLoggedIn ? (
<div></div>
) : (
<Input.Password
placeholder={"password"}
onInput={(v) => setPassword((v.target as HTMLInputElement).value)}
/>
),
key: "1",
},
{
label: (
<Button
onClick={async () => {
if (isLoggedIn) TokenService.delete();
else await TokenService.request(username, password);
setLoginOpen(false);
}}
>
{isLoggedIn ? "خروج" : "ورود"}
</Button>
),
key: "2",
},
];
return (
<>
<header className="flex h-14 w-full items-center border-b border-stone-100">
<Container>
<div className="grid grid-cols-3 items-center">
<div className="justify-self-start">
<Button
type="text"
icon={<HambergerMenu className="stroke-black" size="20" />}
onClick={showDrawer}
/>
</div>
<div className="justify-self-center">
<Image
src="/houshan-icon.png"
alt="هوشان"
width={40}
height={40}
/>
</div>
<div className="justify-self-end">
<Dropdown menu={{ items }} open={loginOpen}>
<Button
type="text"
onClick={() => setLoginOpen((prev) => !prev)}
icon={<User className="stroke-current" size="20" />}
>
{isLoggedIn ? (
<div className="hidden sm:block">{username}</div>
) : (
<div>ورود</div>
)}
</Button>
</Dropdown>
</div>
</div>
</Container>
</header>
<Drawer
title="پنل مدیریت هوشان"
placement="right"
closable={true}
open={open}
onClose={onClose}
closeIcon={<ArrowRight2 className="stroke-current" size="20" />}
footer={<div className="text-center">رهانش 1.0.0</div>}
classNames={{
wrapper: "!shadow-none !w-60",
header: "h-14 !border-none",
content: "!bg-stone-100",
body: "flex flex-col gap-2",
}}
>
<Button
block
type={pathname === "/" ? "primary" : "text"}
icon={<Home2 className="stroke-current" size="20" />}
onClick={() => router.push("/")}
>
<div className="w-20 text-right">خانه</div>
</Button>
<Button
block
type={pathname === "/report" ? "primary" : "text"}
icon={<Chart1 className="stroke-current" size="20" />}
onClick={() => router.push("/report")}
>
<div className="w-20 text-right">گزارشها</div>
</Button>
<Button
block
type={pathname.includes("assistant") ? "primary" : "text"}
icon={<EmojiNormal className="stroke-current" size="20" />}
onClick={() => router.push("/assistant")}
>
<div className="w-20 text-right">دستیارها</div>
</Button>
<Button
block
type={pathname.includes("/ticket") ? "primary" : "text"}
icon={<Direct className="stroke-current" size="20" />}
onClick={() => router.push("/ticket")}
>
<div className="w-20 text-right">تیکتها</div>
</Button>
<Button
block
type={pathname === "/settlement" ? "primary" : "text"}
icon={<Coin className="stroke-current" size="20" />}
onClick={() => router.push("/settlement")}
>
<div className="w-20 text-right">تسویه حساب</div>
</Button>
<Button
block
type={pathname === "/transaction" ? "primary" : "text"}
icon={<Receipt className="stroke-current" size="20" />}
onClick={() => router.push("/transaction")}
>
<div className="w-20 text-right">تراکنشها</div>
</Button>
<Button
block
type={pathname.includes("/user") ? "primary" : "text"}
icon={<Profile2User className="stroke-current" size="20" />}
onClick={() => router.push("/user")}
>
<div className="w-20 text-right">کاربران</div>
</Button>
{/* --- دکمه جدید اضافه شده --- */}
<Button
block
type={pathname === "/user/score" ? "primary" : "text"}
icon={<Gift className="stroke-current" size="20" />}
onClick={() => router.push("/user/score")}
>
<div className="w-20 text-right">امتیازدهی</div>
</Button>
</Drawer>
</>
);
};

11
src/helpers/utils.ts Normal file
View File

@ -0,0 +1,11 @@
export const formatPersianDate = (date: Date, dateStyle: boolean = true) => {
const formatter = new Intl.DateTimeFormat("fa-IR", {
dateStyle: dateStyle ? "medium" : undefined,
timeStyle: "short",
});
return formatter.format(date);
};
export const detectDir = (text: string) =>
/^[\u0600-\u06FF]/.test(text) ? "rtl" : "ltr";

44
src/services/api/index.ts Normal file
View File

@ -0,0 +1,44 @@
import axios, { CancelToken } from "axios";
import API_ROUTES from "./routes";
import TokenService from "../token";
const api = axios.create({
baseURL: process.env.NEXT_PUBLIC_API_BASE_URL,
});
api.interceptors.request.use(
(config) => {
const token = TokenService.get();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
} else {
console.log("Unauthorized");
}
return config;
},
(error) => Promise.reject(error),
);
type OptionalObject = {
[key: string]: unknown;
};
const request = {
get: (url: string, params?: OptionalObject, cancelToken?: CancelToken) =>
api.get(url, { params, cancelToken }),
post: (
url: string,
data?: OptionalObject | FormData,
config?: OptionalObject,
) => api.post(url, data, config),
put: (
url: string,
data?: OptionalObject | FormData,
config?: OptionalObject,
) => api.put(url, data, config),
delete: (url: string, data?: OptionalObject) => api.delete(url, data),
};
export { request, API_ROUTES };

View File

@ -0,0 +1,12 @@
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
const queryClient = new QueryClient();
function ReactQueryClientProvider({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
}
export default ReactQueryClientProvider;

View File

@ -0,0 +1,26 @@
const API_ROUTES = {
ASSISTANT: {
ALL: "/admin/bot/assistant",
ONE: (id: string) => `/admin/bot/assistant/${id}`,
CONFIRM: (id: string) => `/admin/bot/assistant/${id}/confirm`,
REJECT: (id: string) => `/admin/bot/assistant/${id}/reject`,
},
CATEGORY: {
ALL: "/category/",
},
TICKET: {
ALL: "/admin/ticket/",
ONE: (id: string) => `/admin/ticket/${id}`,
},
TRANSACTION: {
ALL: "/admin/transaction/", // اندپوینت دریافت همه تراکنش‌ها
},
USER: {
ALL: "/admin/user/",
ONE: (id: string) => `/admin/user/${id}`,
SCORE: "/admin/user/groupscore",
HISTORY: (id: string) => `/admin/user/transections/${id}`,
},
};
export default API_ROUTES;

View File

@ -0,0 +1,50 @@
const TokenService = {
set: (token: string) => {
if (typeof window !== 'undefined') {
localStorage.setItem("token", token);
}
},
get: () => {
if (typeof window !== 'undefined') {
return localStorage.getItem("token");
}
return null;
},
getUser: () => {
if (typeof window !== 'undefined') {
return localStorage.getItem("username");
}
return null;
},
delete: () => {
if (typeof window !== 'undefined') {
localStorage.removeItem("token");
}
},
request: async (username: string, password: string) => {
const response: { detail: string; access_token: string; token_type: string } = await (
await fetch('https://basa.houshan.ai/admin/user/login', {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify({
username,
password,
}),
})
).json();
if (typeof window !== 'undefined') {
localStorage.setItem('token', response.access_token);
localStorage.setItem('username', username);
}
},
};
export default TokenService;

43
src/types/assistant.ts Normal file
View File

@ -0,0 +1,43 @@
import type { Category } from "./category";
import { User } from "./user";
export type Assistant = {
id: number;
name: string;
description: string;
image: string | null;
created_at: Date;
category: Category;
status: "confirmed" | "pending" | "rejected";
};
export type AssistantDetail = Assistant & {
prompt: string;
cost: number;
user: User;
public: boolean;
links: string[] | null;
docs: string[] | null;
allowed_levels: number[] | null;
};
export enum AssistantStatus {
confirmed,
pending,
rejected,
}
export type AssistantStats = {
id: number;
name: string;
created_at: string;
user: { // اطلاعات سازنده
id: string;
username: string;
name?: string;
};
usage: { // اطلاعات مصرف
total_messages: number; // تعداد کل پیام‌ها
total_tokens: number; // میزان مصرف (توکن یا اعتبار)
};
};

4
src/types/category.ts Normal file
View File

@ -0,0 +1,4 @@
export type Category = {
id: number;
name: string;
};

20
src/types/ticket.ts Normal file
View File

@ -0,0 +1,20 @@
export type Ticket = {
id: string;
image: string;
name?: string;
username: string;
mobile_number?: string;
last_ticket: Message;
};
export type Message = {
id: number;
text: string;
role: "user" | "admin";
file?: string;
created_at: Date;
};
export type TicketHistory = {
[date: string]: Message[];
};

17
src/types/transaction.ts Normal file
View File

@ -0,0 +1,17 @@
// panel-houshan/src/types/transaction.ts
export type TransactionType = "gift" | "credit" | "free";
export type Transaction = {
id: string;
amount: number;
type: TransactionType;
description: string;
created_at: string;
// این فیلد برای نمایش نام کاربر در لیست کلی اضافه می‌شود
user?: {
id: string;
username: string;
name?: string;
};
};

23
src/types/user.ts Normal file
View File

@ -0,0 +1,23 @@
import { Assistant } from "./assistant";
type Notifs = {
title: string;
message: string;
created_at: Date;
};
export type User = {
id: string;
name?: string;
username: string;
mobile_number?: string;
email?: string;
image?: string;
level: number;
sub_users: User[] | null;
};
export type UserDetail = User & {
bots: Assistant[];
notifications: Notifs[];
};

27
tailwind.config.ts Normal file
View File

@ -0,0 +1,27 @@
import type { Config } from "tailwindcss";
export default {
content: [
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {
colors: {
background: "var(--background)",
foreground: "var(--foreground)",
},
container: {
center: true,
screens: {
xl: "1280px",
},
},
screens: {
md: "900px",
},
},
},
plugins: [],
} satisfies Config;

27
tsconfig.json Normal file
View File

@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}

3600
yarn.lock Normal file

File diff suppressed because it is too large Load Diff