feat(user): support user-group assignment in user management

This commit is contained in:
Sagit
2026-02-18 14:47:12 +00:00
parent 42701e6c01
commit d12c5bf2e1
10 changed files with 260 additions and 40 deletions
+21 -5
View File
@@ -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>
+8 -2
View File
@@ -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("");
+26 -22
View File
@@ -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) =>
+49 -1
View File
@@ -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>