mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-10-11 19:56:37 +08:00
fix(frontend): polish batch actions and sharing page guidance (#157)
* fix(frontend): polish batch actions and sharing page guidance * fix(frontend): add top spacing above sharing section banners * fix(frontend): allow manual and calendar expiry date input * fix(frontend): switch batch action buttons to warning tone * fix(frontend): switch batch action buttons to default tone
This commit is contained in:
@@ -1584,7 +1584,7 @@ export default function ForwardPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
color={selectMode ? "warning" : "primary"}
|
color="default"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant={selectMode ? "solid" : "flat"}
|
variant={selectMode ? "solid" : "flat"}
|
||||||
onPress={toggleSelectMode}
|
onPress={toggleSelectMode}
|
||||||
|
|||||||
@@ -645,9 +645,10 @@ export default function GroupPage() {
|
|||||||
{(item) => <SelectItem key={item.id}>{item.name}</SelectItem>}
|
{(item) => <SelectItem key={item.id}>{item.name}</SelectItem>}
|
||||||
</Select>
|
</Select>
|
||||||
<Button
|
<Button
|
||||||
className="md:self-end"
|
className="md:self-end md:justify-self-start whitespace-nowrap px-4"
|
||||||
color="primary"
|
color="primary"
|
||||||
isLoading={savingPermission}
|
isLoading={savingPermission}
|
||||||
|
size="sm"
|
||||||
onPress={handleAssignPermission}
|
onPress={handleAssignPermission}
|
||||||
>
|
>
|
||||||
分配权限
|
分配权限
|
||||||
|
|||||||
@@ -1000,7 +1000,7 @@ export default function NodePage() {
|
|||||||
|
|
||||||
<div className="flex gap-2 items-center">
|
<div className="flex gap-2 items-center">
|
||||||
<Button
|
<Button
|
||||||
color={selectMode ? "warning" : "primary"}
|
color="default"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant={selectMode ? "solid" : "flat"}
|
variant={selectMode ? "solid" : "flat"}
|
||||||
onPress={toggleSelectMode}
|
onPress={toggleSelectMode}
|
||||||
|
|||||||
@@ -397,11 +397,30 @@ export default function PanelSharingPage() {
|
|||||||
selectedKey={selectedTab}
|
selectedKey={selectedTab}
|
||||||
onSelectionChange={(k) => setSelectedTab(k as string)}
|
onSelectionChange={(k) => setSelectedTab(k as string)}
|
||||||
>
|
>
|
||||||
<Tab key="my-shares" title="我分享的 (Provider)">
|
<Tab
|
||||||
|
key="my-shares"
|
||||||
|
title={
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-semibold text-foreground">
|
||||||
|
Provider
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-default-500">我分享的</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
<Card>
|
<Card>
|
||||||
<CardBody>
|
<CardBody className="space-y-5 pt-4 md:pt-6">
|
||||||
<div className="mb-4">
|
<div className="flex flex-col gap-4 rounded-lg border border-divider bg-default-50/60 dark:bg-default-100/20 p-4 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h2 className="text-lg font-semibold text-foreground">
|
||||||
|
Provider 共享
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-default-500">
|
||||||
|
将本地节点分享给其他面板,统一管理 Token、端口范围和到期策略。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<Button
|
<Button
|
||||||
|
className="self-start md:self-auto"
|
||||||
color="primary"
|
color="primary"
|
||||||
onPress={() => setCreateShareOpen(true)}
|
onPress={() => setCreateShareOpen(true)}
|
||||||
>
|
>
|
||||||
@@ -410,9 +429,25 @@ export default function PanelSharingPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="text-center py-10 text-gray-500">加载中...</div>
|
<div className="text-center py-12 text-default-500">加载中...</div>
|
||||||
) : shares.length === 0 ? (
|
) : shares.length === 0 ? (
|
||||||
<div className="text-center py-10 text-gray-500">暂无分享</div>
|
<div className="rounded-lg border border-dashed border-divider bg-default-50/60 dark:bg-default-100/20 px-6 py-10 text-center">
|
||||||
|
<p className="text-base font-semibold text-foreground">
|
||||||
|
暂无分享
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-sm text-default-500">
|
||||||
|
先创建一个分享,把本地节点开放给其他面板使用。
|
||||||
|
</p>
|
||||||
|
<div className="mt-5 flex justify-center">
|
||||||
|
<Button
|
||||||
|
color="primary"
|
||||||
|
variant="flat"
|
||||||
|
onPress={() => setCreateShareOpen(true)}
|
||||||
|
>
|
||||||
|
创建第一个分享
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
{shares.map((share) => (
|
{shares.map((share) => (
|
||||||
@@ -507,11 +542,30 @@ export default function PanelSharingPage() {
|
|||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
</Tab>
|
</Tab>
|
||||||
<Tab key="remote-nodes" title="远程节点 (Consumer)">
|
<Tab
|
||||||
|
key="remote-nodes"
|
||||||
|
title={
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-semibold text-foreground">
|
||||||
|
Consumer
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-default-500">远程节点</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
<Card>
|
<Card>
|
||||||
<CardBody>
|
<CardBody className="space-y-5 pt-4 md:pt-6">
|
||||||
<div className="mb-4">
|
<div className="flex flex-col gap-4 rounded-lg border border-divider bg-default-50/60 dark:bg-default-100/20 p-4 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h2 className="text-lg font-semibold text-foreground">
|
||||||
|
Consumer 接入
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-default-500">
|
||||||
|
导入远程节点后,可在这里查看端口占用和同步状态。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<Button
|
<Button
|
||||||
|
className="self-start md:self-auto"
|
||||||
color="secondary"
|
color="secondary"
|
||||||
onPress={() => setImportNodeOpen(true)}
|
onPress={() => setImportNodeOpen(true)}
|
||||||
>
|
>
|
||||||
@@ -520,13 +574,24 @@ export default function PanelSharingPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{remoteUsageLoading ? (
|
{remoteUsageLoading ? (
|
||||||
<div className="text-center py-10 text-gray-500">加载中...</div>
|
<div className="text-center py-12 text-default-500">加载中...</div>
|
||||||
) : remoteUsageNodes.length === 0 ? (
|
) : remoteUsageNodes.length === 0 ? (
|
||||||
<div className="text-center py-10 text-gray-500">
|
<div className="rounded-lg border border-dashed border-divider bg-default-50/60 dark:bg-default-100/20 px-6 py-10 text-center">
|
||||||
<p>暂无远程节点占用记录。</p>
|
<p className="text-base font-semibold text-foreground">
|
||||||
<p className="mt-2">
|
暂无远程节点占用记录
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-sm text-default-500">
|
||||||
导入远程节点并创建隧道后,这里会显示远端端口占用情况。
|
导入远程节点并创建隧道后,这里会显示远端端口占用情况。
|
||||||
</p>
|
</p>
|
||||||
|
<div className="mt-5 flex justify-center">
|
||||||
|
<Button
|
||||||
|
color="secondary"
|
||||||
|
variant="flat"
|
||||||
|
onPress={() => setImportNodeOpen(true)}
|
||||||
|
>
|
||||||
|
去导入远程节点
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
|||||||
@@ -678,7 +678,7 @@ export default function TunnelPage() {
|
|||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
color={selectMode ? "warning" : "primary"}
|
color="default"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant={selectMode ? "solid" : "flat"}
|
variant={selectMode ? "solid" : "flat"}
|
||||||
onPress={toggleSelectMode}
|
onPress={toggleSelectMode}
|
||||||
|
|||||||
@@ -943,7 +943,6 @@ export default function UserPage() {
|
|||||||
<DatePicker
|
<DatePicker
|
||||||
isRequired
|
isRequired
|
||||||
showMonthAndYearPickers
|
showMonthAndYearPickers
|
||||||
className="cursor-pointer"
|
|
||||||
label="过期时间"
|
label="过期时间"
|
||||||
value={
|
value={
|
||||||
userForm.expTime
|
userForm.expTime
|
||||||
@@ -1167,17 +1166,22 @@ export default function UserPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
className="w-full sm:w-auto"
|
<Button
|
||||||
color="primary"
|
className="w-full sm:w-auto"
|
||||||
isDisabled={batchTunnelSelections.size === 0}
|
color="primary"
|
||||||
isLoading={assignLoading}
|
isDisabled={batchTunnelSelections.size === 0}
|
||||||
onPress={handleBatchAssignTunnel}
|
isLoading={assignLoading}
|
||||||
>
|
onPress={handleBatchAssignTunnel}
|
||||||
分配权限{" "}
|
>
|
||||||
{batchTunnelSelections.size > 0 &&
|
分配权限
|
||||||
`(${batchTunnelSelections.size}个隧道)`}
|
</Button>
|
||||||
</Button>
|
{batchTunnelSelections.size > 0 && (
|
||||||
|
<Chip color="primary" size="sm" variant="flat">
|
||||||
|
已选 {batchTunnelSelections.size} 个隧道
|
||||||
|
</Chip>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1434,7 +1438,6 @@ export default function UserPage() {
|
|||||||
<DatePicker
|
<DatePicker
|
||||||
isRequired
|
isRequired
|
||||||
showMonthAndYearPickers
|
showMonthAndYearPickers
|
||||||
className="cursor-pointer"
|
|
||||||
label="到期时间"
|
label="到期时间"
|
||||||
value={
|
value={
|
||||||
editTunnelForm.expTime
|
editTunnelForm.expTime
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
import { CalendarIcon } from "lucide-react";
|
||||||
|
|
||||||
import { FieldContainer, type FieldMetaProps } from "./shared";
|
import { FieldContainer, type FieldMetaProps } from "./shared";
|
||||||
|
|
||||||
@@ -11,6 +12,50 @@ interface CalendarDateLike {
|
|||||||
year: number;
|
year: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isValidCalendarDate(year: number, month: number, day: number) {
|
||||||
|
if (
|
||||||
|
!Number.isInteger(year) ||
|
||||||
|
!Number.isInteger(month) ||
|
||||||
|
!Number.isInteger(day)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (month < 1 || month > 12 || day < 1 || day > 31) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const candidate = new Date(year, month - 1, day);
|
||||||
|
|
||||||
|
return (
|
||||||
|
candidate.getFullYear() === year &&
|
||||||
|
candidate.getMonth() === month - 1 &&
|
||||||
|
candidate.getDate() === day
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDateText(value: string) {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
|
||||||
|
if (!trimmed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const matched = trimmed.match(/^(\d{4})[-/.](\d{1,2})[-/.](\d{1,2})$/);
|
||||||
|
|
||||||
|
if (!matched) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const year = Number(matched[1]);
|
||||||
|
const month = Number(matched[2]);
|
||||||
|
const day = Number(matched[3]);
|
||||||
|
|
||||||
|
if (!isValidCalendarDate(year, month, day)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { day, month, year };
|
||||||
|
}
|
||||||
|
|
||||||
export interface DatePickerProps extends FieldMetaProps {
|
export interface DatePickerProps extends FieldMetaProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
isDisabled?: boolean;
|
isDisabled?: boolean;
|
||||||
@@ -39,9 +84,105 @@ export function DatePicker({
|
|||||||
isRequired,
|
isRequired,
|
||||||
label,
|
label,
|
||||||
onChange,
|
onChange,
|
||||||
|
showMonthAndYearPickers,
|
||||||
value,
|
value,
|
||||||
}: DatePickerProps) {
|
}: DatePickerProps) {
|
||||||
const id = React.useId();
|
const id = React.useId();
|
||||||
|
const nativePickerRef = React.useRef<HTMLInputElement | null>(null);
|
||||||
|
const formattedValue = React.useMemo(() => formatDateValue(value), [value]);
|
||||||
|
const [textValue, setTextValue] = React.useState(formattedValue);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
setTextValue(formattedValue);
|
||||||
|
}, [formattedValue]);
|
||||||
|
|
||||||
|
const shouldUseTextInput = Boolean(showMonthAndYearPickers);
|
||||||
|
|
||||||
|
const notifyNativeDateChange = (rawValue: string) => {
|
||||||
|
if (!onChange) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!rawValue) {
|
||||||
|
onChange(null);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [yearText, monthText, dayText] = rawValue.split("-");
|
||||||
|
const year = Number(yearText);
|
||||||
|
const month = Number(monthText);
|
||||||
|
const day = Number(dayText);
|
||||||
|
|
||||||
|
if (!isValidCalendarDate(year, month, day)) {
|
||||||
|
onChange(null);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onChange({ day, month, year });
|
||||||
|
};
|
||||||
|
|
||||||
|
const notifyTextDateChange = (rawValue: string) => {
|
||||||
|
if (!onChange) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!rawValue.trim()) {
|
||||||
|
onChange(null);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = parseDateText(rawValue);
|
||||||
|
|
||||||
|
if (parsed) {
|
||||||
|
onChange(parsed);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const commitTextInput = () => {
|
||||||
|
const parsed = parseDateText(textValue);
|
||||||
|
|
||||||
|
if (parsed) {
|
||||||
|
setTextValue(formatDateValue(parsed));
|
||||||
|
onChange?.(parsed);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!textValue.trim()) {
|
||||||
|
onChange?.(null);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTextValue(formattedValue);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openNativePicker = () => {
|
||||||
|
if (isDisabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const picker = nativePickerRef.current;
|
||||||
|
|
||||||
|
if (!picker) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pickerWithShow = picker as HTMLInputElement & {
|
||||||
|
showPicker?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof pickerWithShow.showPicker === "function") {
|
||||||
|
pickerWithShow.showPicker();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
picker.focus();
|
||||||
|
picker.click();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FieldContainer
|
<FieldContainer
|
||||||
@@ -52,43 +193,71 @@ export function DatePicker({
|
|||||||
isRequired={isRequired}
|
isRequired={isRequired}
|
||||||
label={label}
|
label={label}
|
||||||
>
|
>
|
||||||
<Input
|
{shouldUseTextInput ? (
|
||||||
aria-invalid={isInvalid}
|
<div className="flex items-center gap-2">
|
||||||
className={cn(className)}
|
<Input
|
||||||
disabled={isDisabled}
|
aria-invalid={isInvalid}
|
||||||
id={id}
|
className={cn("flex-1", className)}
|
||||||
required={isRequired}
|
disabled={isDisabled}
|
||||||
type="date"
|
id={id}
|
||||||
value={formatDateValue(value)}
|
inputMode="numeric"
|
||||||
onChange={(event) => {
|
placeholder="YYYY-MM-DD"
|
||||||
if (!onChange) {
|
required={isRequired}
|
||||||
return;
|
type="text"
|
||||||
}
|
value={textValue}
|
||||||
|
onBlur={commitTextInput}
|
||||||
|
onChange={(event) => {
|
||||||
|
const nextValue = event.target.value;
|
||||||
|
|
||||||
if (!event.target.value) {
|
setTextValue(nextValue);
|
||||||
onChange(null);
|
notifyTextDateChange(nextValue);
|
||||||
|
}}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
event.preventDefault();
|
||||||
|
commitTextInput();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
aria-label="打开日历选择器"
|
||||||
|
className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-md border border-input bg-background text-default-600 shadow-sm transition-colors hover:bg-default-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
disabled={isDisabled}
|
||||||
|
type="button"
|
||||||
|
onClick={openNativePicker}
|
||||||
|
>
|
||||||
|
<CalendarIcon className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
ref={nativePickerRef}
|
||||||
|
aria-hidden="true"
|
||||||
|
className="sr-only"
|
||||||
|
disabled={isDisabled}
|
||||||
|
tabIndex={-1}
|
||||||
|
type="date"
|
||||||
|
value={formattedValue}
|
||||||
|
onChange={(event) => {
|
||||||
|
const nextValue = event.target.value;
|
||||||
|
|
||||||
return;
|
setTextValue(nextValue);
|
||||||
}
|
notifyNativeDateChange(nextValue);
|
||||||
|
}}
|
||||||
const [yearText, monthText, dayText] = event.target.value.split("-");
|
/>
|
||||||
const year = Number(yearText);
|
</div>
|
||||||
const month = Number(monthText);
|
) : (
|
||||||
const day = Number(dayText);
|
<Input
|
||||||
|
aria-invalid={isInvalid}
|
||||||
if (
|
className={cn(className)}
|
||||||
!Number.isFinite(year) ||
|
disabled={isDisabled}
|
||||||
!Number.isFinite(month) ||
|
id={id}
|
||||||
!Number.isFinite(day)
|
required={isRequired}
|
||||||
) {
|
type="date"
|
||||||
onChange(null);
|
value={formattedValue}
|
||||||
|
onChange={(event) => {
|
||||||
return;
|
notifyNativeDateChange(event.target.value);
|
||||||
}
|
}}
|
||||||
|
/>
|
||||||
onChange({ day, month, year });
|
)}
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</FieldContainer>
|
</FieldContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user