mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-10-02 17:06:36 +08:00
feat(user): support user-group assignment in user management
This commit is contained in:
@@ -131,6 +131,9 @@ export const updatePassword = (data: any) =>
|
||||
export const resetUserFlow = (data: { id: number; type: number }) =>
|
||||
Network.post("/user/reset", data);
|
||||
|
||||
export const getUserGroups = (id: number) =>
|
||||
Network.post<number[]>("/user/groups", { id });
|
||||
|
||||
// 网站配置相关接口
|
||||
export const getConfigs = () => Network.post("/config/list");
|
||||
export const getConfigByName = (name: string) =>
|
||||
@@ -141,8 +144,10 @@ export const updateConfig = (name: string, value: string) =>
|
||||
Network.post("/config/update-single", { name, value });
|
||||
|
||||
export const exportBackupData = () => Network.post("/backup/export");
|
||||
export const importBackupData = (data: any) => Network.post("/backup/import", data);
|
||||
export const restoreBackupData = (data: any) => Network.post("/backup/restore", data);
|
||||
export const importBackupData = (data: any) =>
|
||||
Network.post("/backup/import", data);
|
||||
export const restoreBackupData = (data: any) =>
|
||||
Network.post("/backup/restore", data);
|
||||
|
||||
// 验证码相关接口
|
||||
export const checkCaptcha = () => Network.post("/captcha/check");
|
||||
@@ -291,5 +296,7 @@ export interface AnnouncementData {
|
||||
enabled: number;
|
||||
}
|
||||
|
||||
export const getAnnouncement = () => Network.get<AnnouncementData>("/announcement/get");
|
||||
export const updateAnnouncement = (data: AnnouncementData) => Network.post("/announcement/update", data);
|
||||
export const getAnnouncement = () =>
|
||||
Network.get<AnnouncementData>("/announcement/get");
|
||||
export const updateAnnouncement = (data: AnnouncementData) =>
|
||||
Network.post("/announcement/update", data);
|
||||
|
||||
@@ -18,7 +18,14 @@ import {
|
||||
} from "@heroui/modal";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { updateConfigs, exportBackup, importBackup, getAnnouncement, updateAnnouncement, type AnnouncementData } from "@/api";
|
||||
import {
|
||||
updateConfigs,
|
||||
exportBackup,
|
||||
importBackup,
|
||||
getAnnouncement,
|
||||
updateAnnouncement,
|
||||
type AnnouncementData,
|
||||
} from "@/api";
|
||||
import { SettingsIcon } from "@/components/icons";
|
||||
import { isAdmin } from "@/utils/auth";
|
||||
import {
|
||||
@@ -501,7 +508,9 @@ export default function ConfigPage() {
|
||||
<Button
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={() => setTypes(allSelected ? [] : [...BACKUP_TYPE_VALUES])}
|
||||
onPress={() =>
|
||||
setTypes(allSelected ? [] : [...BACKUP_TYPE_VALUES])
|
||||
}
|
||||
>
|
||||
{allSelected ? "取消全选" : "全选"}
|
||||
</Button>
|
||||
@@ -675,7 +684,10 @@ export default function ConfigPage() {
|
||||
<Switch
|
||||
isSelected={announcement.enabled === 1}
|
||||
onValueChange={(checked) =>
|
||||
setAnnouncement({ ...announcement, enabled: checked ? 1 : 0 })
|
||||
setAnnouncement({
|
||||
...announcement,
|
||||
enabled: checked ? 1 : 0,
|
||||
})
|
||||
}
|
||||
>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">
|
||||
@@ -689,10 +701,10 @@ export default function ConfigPage() {
|
||||
|
||||
<Textarea
|
||||
label="公告内容"
|
||||
minRows={4}
|
||||
placeholder="请输入公告内容"
|
||||
value={announcement.content}
|
||||
variant="bordered"
|
||||
minRows={4}
|
||||
onChange={(e) =>
|
||||
setAnnouncement({ ...announcement, content: e.target.value })
|
||||
}
|
||||
@@ -801,7 +813,11 @@ export default function ConfigPage() {
|
||||
<Button variant="light" onPress={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button color="primary" isLoading={exporting} onPress={handleExport}>
|
||||
<Button
|
||||
color="primary"
|
||||
isLoading={exporting}
|
||||
onPress={handleExport}
|
||||
>
|
||||
{exporting ? "导出中..." : "确认导出"}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
|
||||
@@ -13,7 +13,11 @@ import {
|
||||
ResponsiveContainer,
|
||||
} from "recharts";
|
||||
|
||||
import { getUserPackageInfo, getAnnouncement, type AnnouncementData } from "@/api";
|
||||
import {
|
||||
getUserPackageInfo,
|
||||
getAnnouncement,
|
||||
type AnnouncementData,
|
||||
} from "@/api";
|
||||
|
||||
interface UserInfo {
|
||||
flow: number;
|
||||
@@ -71,7 +75,9 @@ export default function DashboardPage() {
|
||||
const [forwardList, setForwardList] = useState<Forward[]>([]);
|
||||
const [statisticsFlows, setStatisticsFlows] = useState<StatisticsFlow[]>([]);
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const [announcement, setAnnouncement] = useState<AnnouncementData | null>(null);
|
||||
const [announcement, setAnnouncement] = useState<AnnouncementData | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const [addressModalOpen, setAddressModalOpen] = useState(false);
|
||||
const [addressModalTitle, setAddressModalTitle] = useState("");
|
||||
|
||||
@@ -317,22 +317,22 @@ export default function TunnelPage() {
|
||||
|
||||
// 直接使用列表数据,getAllTunnels 已经包含完整的节点信息
|
||||
setForm({
|
||||
id: tunnel.id,
|
||||
name: tunnel.name,
|
||||
type: tunnel.type,
|
||||
inNodeId: tunnel.inNodeId || [],
|
||||
outNodeId: tunnel.outNodeId || [],
|
||||
chainNodes: tunnel.chainNodes || [],
|
||||
flow: tunnel.flow,
|
||||
trafficRatio: tunnel.trafficRatio,
|
||||
inIp: tunnel.inIp
|
||||
? tunnel.inIp
|
||||
.split(",")
|
||||
.map((ip: string) => ip.trim())
|
||||
.join("\n")
|
||||
: "",
|
||||
ipPreference: tunnel.ipPreference || "",
|
||||
status: tunnel.status,
|
||||
id: tunnel.id,
|
||||
name: tunnel.name,
|
||||
type: tunnel.type,
|
||||
inNodeId: tunnel.inNodeId || [],
|
||||
outNodeId: tunnel.outNodeId || [],
|
||||
chainNodes: tunnel.chainNodes || [],
|
||||
flow: tunnel.flow,
|
||||
trafficRatio: tunnel.trafficRatio,
|
||||
inIp: tunnel.inIp
|
||||
? tunnel.inIp
|
||||
.split(",")
|
||||
.map((ip: string) => ip.trim())
|
||||
.join("\n")
|
||||
: "",
|
||||
ipPreference: tunnel.ipPreference || "",
|
||||
status: tunnel.status,
|
||||
});
|
||||
setErrors({});
|
||||
setModalOpen(true);
|
||||
@@ -1050,7 +1050,9 @@ export default function TunnelPage() {
|
||||
</div>
|
||||
|
||||
{/* 流量配置 */}
|
||||
<div className={`grid gap-2 ${tunnel.type === 2 && tunnel.ipPreference ? "grid-cols-3" : "grid-cols-2"}`}>
|
||||
<div
|
||||
className={`grid gap-2 ${tunnel.type === 2 && tunnel.ipPreference ? "grid-cols-3" : "grid-cols-2"}`}
|
||||
>
|
||||
<div className="text-center p-1.5 bg-default-50 dark:bg-default-100/30 rounded">
|
||||
<div className="text-xs text-default-500">
|
||||
流量计算
|
||||
@@ -1073,7 +1075,9 @@ export default function TunnelPage() {
|
||||
连接偏好
|
||||
</div>
|
||||
<div className="text-sm font-semibold text-foreground mt-0.5">
|
||||
{tunnel.ipPreference === "v4" ? "IPv4" : "IPv6"}
|
||||
{tunnel.ipPreference === "v4"
|
||||
? "IPv4"
|
||||
: "IPv6"}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -1280,11 +1284,11 @@ export default function TunnelPage() {
|
||||
errorMessage={errors.trafficRatio}
|
||||
isInvalid={!!errors.trafficRatio}
|
||||
label="流量倍率"
|
||||
placeholder="例如:0.5 或 1 或 2"
|
||||
type="number"
|
||||
step="any"
|
||||
min={0.01}
|
||||
max={100}
|
||||
min={0.01}
|
||||
placeholder="例如:0.5 或 1 或 2"
|
||||
step="any"
|
||||
type="number"
|
||||
value={form.trafficRatio.toString()}
|
||||
variant="bordered"
|
||||
onChange={(e) =>
|
||||
|
||||
@@ -31,6 +31,7 @@ import { parseDate } from "@internationalized/date";
|
||||
import {
|
||||
User,
|
||||
UserForm,
|
||||
UserGroup,
|
||||
UserTunnel,
|
||||
TunnelAssignItem,
|
||||
Tunnel,
|
||||
@@ -49,6 +50,8 @@ import {
|
||||
updateUserTunnel,
|
||||
getSpeedLimitList,
|
||||
resetUserFlow,
|
||||
getUserGroupList,
|
||||
getUserGroups,
|
||||
} from "@/api";
|
||||
import {
|
||||
SearchIcon,
|
||||
@@ -204,12 +207,14 @@ export default function UserPage() {
|
||||
// 其他数据
|
||||
const [tunnels, setTunnels] = useState<Tunnel[]>([]);
|
||||
const [speedLimits, setSpeedLimits] = useState<SpeedLimit[]>([]);
|
||||
const [userGroups, setUserGroups] = useState<UserGroup[]>([]);
|
||||
|
||||
// 生命周期
|
||||
useEffect(() => {
|
||||
loadUsers();
|
||||
loadTunnels();
|
||||
loadSpeedLimits();
|
||||
loadUserGroups();
|
||||
}, [pagination.current, pagination.size, searchKeyword]);
|
||||
|
||||
// 数据加载函数
|
||||
@@ -256,6 +261,16 @@ export default function UserPage() {
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const loadUserGroups = async () => {
|
||||
try {
|
||||
const response = await getUserGroupList();
|
||||
|
||||
if (response.code === 0) {
|
||||
setUserGroups(response.data || []);
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const loadUserTunnels = async (userId: number) => {
|
||||
setTunnelListLoading(true);
|
||||
try {
|
||||
@@ -289,12 +304,23 @@ export default function UserPage() {
|
||||
num: 10,
|
||||
expTime: null,
|
||||
flowResetTime: 0,
|
||||
groupIds: [],
|
||||
});
|
||||
onUserModalOpen();
|
||||
};
|
||||
|
||||
const handleEdit = (user: User) => {
|
||||
const handleEdit = async (user: User) => {
|
||||
setIsEdit(true);
|
||||
let currentGroupIds: number[] = [];
|
||||
|
||||
try {
|
||||
const groupRes = await getUserGroups(user.id);
|
||||
|
||||
if (groupRes.code === 0) {
|
||||
currentGroupIds = groupRes.data || [];
|
||||
}
|
||||
} catch {}
|
||||
|
||||
setUserForm({
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
@@ -305,6 +331,7 @@ export default function UserPage() {
|
||||
num: user.num,
|
||||
expTime: user.expTime ? new Date(user.expTime) : null,
|
||||
flowResetTime: user.flowResetTime ?? 0,
|
||||
groupIds: currentGroupIds,
|
||||
});
|
||||
onUserModalOpen();
|
||||
};
|
||||
@@ -345,6 +372,7 @@ export default function UserPage() {
|
||||
const submitData: any = {
|
||||
...userForm,
|
||||
expTime: userForm.expTime.getTime(),
|
||||
groupIds: userForm.groupIds ?? [],
|
||||
};
|
||||
|
||||
if (isEdit && !submitData.pwd) {
|
||||
@@ -951,6 +979,26 @@ export default function UserPage() {
|
||||
<Radio value="1">正常</Radio>
|
||||
<Radio value="0">禁用</Radio>
|
||||
</RadioGroup>
|
||||
|
||||
{userGroups.length > 0 && (
|
||||
<Select
|
||||
label="用户分组(可选)"
|
||||
placeholder="选择要加入的分组"
|
||||
selectedKeys={new Set((userForm.groupIds ?? []).map(String))}
|
||||
selectionMode="multiple"
|
||||
onSelectionChange={(keys) => {
|
||||
const selected = Array.from(keys as Set<string>).map(Number);
|
||||
|
||||
setUserForm((prev) => ({ ...prev, groupIds: selected }));
|
||||
}}
|
||||
>
|
||||
{userGroups.map((g) => (
|
||||
<SelectItem key={g.id.toString()} textValue={g.name}>
|
||||
{g.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button onPress={onUserModalClose}>取消</Button>
|
||||
|
||||
@@ -20,6 +20,12 @@ export interface User {
|
||||
outFlow?: number; // 上传流量(字节)
|
||||
}
|
||||
|
||||
export interface UserGroup {
|
||||
id: number;
|
||||
name: string;
|
||||
status: number;
|
||||
}
|
||||
|
||||
export interface UserForm {
|
||||
id?: number;
|
||||
name?: string;
|
||||
@@ -30,6 +36,7 @@ export interface UserForm {
|
||||
num: number;
|
||||
expTime: Date | null;
|
||||
flowResetTime: number;
|
||||
groupIds?: number[];
|
||||
}
|
||||
|
||||
export interface UserTunnel {
|
||||
|
||||
Reference in New Issue
Block a user