feat: update main chat and system chat UI
This commit is contained in:
740
src/app/main/TokenSettingManagementPage.tsx
Normal file
740
src/app/main/TokenSettingManagementPage.tsx
Normal file
@@ -0,0 +1,740 @@
|
||||
import { DeleteOutlined, EditOutlined, PlusOutlined, SaveOutlined, UnorderedListOutlined } from '@ant-design/icons';
|
||||
import { Alert, App, Button, Card, Checkbox, Descriptions, Empty, Form, Input, InputNumber, List, Modal, Space, Switch, Tabs, Tag, Typography } from 'antd';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { getReadyPlayAppEntries } from '../../views/play/apps/apps/appsRegistry';
|
||||
import { confirmWithKeyboard } from './modalKeyboard';
|
||||
import {
|
||||
deleteTokenSetting,
|
||||
type TokenSettingRecord,
|
||||
upsertTokenSetting,
|
||||
useTokenSettingRegistry,
|
||||
} from './tokenSettingAccess';
|
||||
import { useTokenAccess } from './tokenAccess';
|
||||
import './TokenSettingManagementPage.css';
|
||||
|
||||
const { Paragraph, Text, Title } = Typography;
|
||||
|
||||
type TokenSettingFormValue = {
|
||||
originalId?: string;
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
defaultExpiresInMinutes: number;
|
||||
maxTokensPer30Days: number;
|
||||
maxTokensPer7Days: number;
|
||||
maxTokensPer5Hours: number;
|
||||
oneTimeTokenLimit: number;
|
||||
allowedAppIds: string[];
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
type AppOption = {
|
||||
value: string;
|
||||
label: string;
|
||||
description: string;
|
||||
category: '관리' | 'Play';
|
||||
};
|
||||
|
||||
type SharedTokenSettingPreview = {
|
||||
id: string;
|
||||
name: string;
|
||||
defaultExpiresInMinutes: number;
|
||||
maxTokensPer30Days: number;
|
||||
maxTokensPer7Days: number;
|
||||
maxTokensPer5Hours: number;
|
||||
oneTimeTokenLimit: number;
|
||||
allowedAppIds: string[];
|
||||
};
|
||||
|
||||
type SharedTokenSettingAccess = {
|
||||
shareToken: string;
|
||||
canManage: boolean;
|
||||
};
|
||||
|
||||
const MANAGEMENT_APP_OPTIONS: AppOption[] = [
|
||||
{ value: 'chat-live', label: 'Codex Live', description: '채팅방 조회와 응답 흐름 진입', category: '관리' },
|
||||
{ value: 'chat-rooms', label: '시스템 채팅방', description: '메뉴별 시스템 채팅방 화면 접근', category: '관리' },
|
||||
{ value: 'chat-room-settings', label: '채팅방 설정', description: 'Codex Live 채팅방 Context 설정 편집 접근', category: '관리' },
|
||||
{ value: 'token-setting', label: '토큰관리 설정', description: '토큰 설정 목록과 상세 편집 접근', category: '관리' },
|
||||
{ value: 'shared-resource', label: '공유 리소스 관리', description: '공유 링크와 권한, 활동 이력 관리', category: '관리' },
|
||||
{ value: 'plan-board', label: '자동화 현황', description: '작업 현황과 요청 보드 접근', category: '관리' },
|
||||
{ value: 'app-settings', label: '앱 설정', description: '헤더 설정, 알림, 업데이트 화면 접근', category: '관리' },
|
||||
{ value: 'resource-manager', label: '리소스 관리', description: '세션 리소스와 파일 미리보기 접근', category: '관리' },
|
||||
{ value: 'error-log', label: '에러 로그', description: '앱 로그와 장애 이력 조회', category: '관리' },
|
||||
];
|
||||
|
||||
const PLAY_APP_OPTIONS: AppOption[] = getReadyPlayAppEntries().map((entry) => ({
|
||||
value: entry.id,
|
||||
label: entry.name,
|
||||
description: entry.searchDescription ?? `${entry.name} 앱 실행`,
|
||||
category: 'Play',
|
||||
}));
|
||||
|
||||
const APP_OPTIONS: AppOption[] = [...MANAGEMENT_APP_OPTIONS, ...PLAY_APP_OPTIONS];
|
||||
const APP_OPTION_LABEL_MAP = new Map(APP_OPTIONS.map((item) => [item.value, item.label] as const));
|
||||
|
||||
const EMPTY_FORM_VALUE: TokenSettingFormValue = {
|
||||
id: '',
|
||||
name: '',
|
||||
description: '',
|
||||
defaultExpiresInMinutes: 60,
|
||||
maxTokensPer30Days: 0,
|
||||
maxTokensPer7Days: 100_000,
|
||||
maxTokensPer5Hours: 100_000,
|
||||
oneTimeTokenLimit: 0,
|
||||
allowedAppIds: [],
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
function normalizeSettingId(value: string | null | undefined) {
|
||||
return String(value ?? '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/[^a-z0-9._-]/g, '');
|
||||
}
|
||||
|
||||
function toFormValue(setting: TokenSettingRecord | null): TokenSettingFormValue {
|
||||
if (!setting) {
|
||||
return EMPTY_FORM_VALUE;
|
||||
}
|
||||
|
||||
return {
|
||||
originalId: setting.id,
|
||||
id: setting.id,
|
||||
name: setting.name,
|
||||
description: setting.description,
|
||||
defaultExpiresInMinutes: setting.defaultExpiresInMinutes,
|
||||
maxTokensPer30Days: setting.maxTokensPer30Days,
|
||||
maxTokensPer7Days: setting.maxTokensPer7Days,
|
||||
maxTokensPer5Hours: setting.maxTokensPer5Hours,
|
||||
oneTimeTokenLimit: setting.oneTimeTokenLimit,
|
||||
allowedAppIds: setting.allowedAppIds,
|
||||
enabled: setting.enabled,
|
||||
};
|
||||
}
|
||||
|
||||
function formatDuration(minutes: number) {
|
||||
if (minutes <= 0) {
|
||||
return '무제한';
|
||||
}
|
||||
|
||||
if (minutes % (60 * 24) === 0) {
|
||||
return `${minutes / (60 * 24)}일`;
|
||||
}
|
||||
|
||||
if (minutes % 60 === 0) {
|
||||
return `${minutes / 60}시간`;
|
||||
}
|
||||
|
||||
return `${minutes}분`;
|
||||
}
|
||||
|
||||
function formatTokenLimit(value: number) {
|
||||
if (value <= 0) {
|
||||
return '무제한';
|
||||
}
|
||||
|
||||
return `${value.toLocaleString('ko-KR')} 토큰`;
|
||||
}
|
||||
|
||||
function formatUnlimitedNumberInput(value: string | number | null | undefined, unit: string) {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
const normalized = Number(String(value).replace(/[^\d.-]/g, ''));
|
||||
|
||||
if (!Number.isFinite(normalized)) {
|
||||
return String(value);
|
||||
}
|
||||
|
||||
if (normalized <= 0) {
|
||||
return '무제한';
|
||||
}
|
||||
|
||||
return `${normalized.toLocaleString('ko-KR')} ${unit}`;
|
||||
}
|
||||
|
||||
function parseUnlimitedNumberInput(value: string | undefined) {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (value.includes('무제한')) {
|
||||
return '0';
|
||||
}
|
||||
|
||||
return value.replace(/[^\d]/g, '');
|
||||
}
|
||||
|
||||
function formatQuotaSummary(setting: TokenSettingRecord) {
|
||||
return [`7일 ${formatTokenLimit(setting.maxTokensPer7Days)}`, `5시간 ${formatTokenLimit(setting.maxTokensPer5Hours)}`].join(' / ');
|
||||
}
|
||||
|
||||
function resolveAppLabels(appIds: string[]) {
|
||||
return appIds.map((item) => APP_OPTION_LABEL_MAP.get(item) ?? item);
|
||||
}
|
||||
|
||||
export function TokenSettingManagementPage({
|
||||
sharedPreviewTokenSetting = null,
|
||||
sharedAccess = null,
|
||||
}: {
|
||||
sharedPreviewTokenSetting?: SharedTokenSettingPreview | null;
|
||||
sharedAccess?: SharedTokenSettingAccess | null;
|
||||
}) {
|
||||
const { message } = App.useApp();
|
||||
const { hasAccess } = useTokenAccess();
|
||||
const isSharedManageMode = !hasAccess && Boolean(sharedAccess?.canManage && sharedAccess.shareToken);
|
||||
const isSharedPreviewMode = !hasAccess && Boolean(sharedPreviewTokenSetting) && !isSharedManageMode;
|
||||
const sharedPreviewRecord = useMemo<TokenSettingRecord | null>(
|
||||
() =>
|
||||
sharedPreviewTokenSetting
|
||||
? {
|
||||
id: sharedPreviewTokenSetting.id,
|
||||
name: sharedPreviewTokenSetting.name,
|
||||
description: '',
|
||||
defaultExpiresInMinutes: sharedPreviewTokenSetting.defaultExpiresInMinutes,
|
||||
maxExpiresInMinutes: sharedPreviewTokenSetting.defaultExpiresInMinutes,
|
||||
maxTokensPer30Days: sharedPreviewTokenSetting.maxTokensPer30Days,
|
||||
maxTokensPer7Days: sharedPreviewTokenSetting.maxTokensPer7Days,
|
||||
maxTokensPer5Hours: sharedPreviewTokenSetting.maxTokensPer5Hours,
|
||||
oneTimeTokenLimit: sharedPreviewTokenSetting.oneTimeTokenLimit,
|
||||
allowedAppIds: sharedPreviewTokenSetting.allowedAppIds,
|
||||
enabled: true,
|
||||
updatedAt: '',
|
||||
}
|
||||
: null,
|
||||
[sharedPreviewTokenSetting],
|
||||
);
|
||||
const { tokenSettings, setTokenSettings, isLoading, errorMessage } = useTokenSettingRegistry(
|
||||
hasAccess || isSharedManageMode,
|
||||
isSharedManageMode ? { shareToken: sharedAccess?.shareToken } : undefined,
|
||||
);
|
||||
const [selectedTokenSettingId, setSelectedTokenSettingId] = useState<string | null>(
|
||||
sharedPreviewTokenSetting?.id ?? tokenSettings[0]?.id ?? null,
|
||||
);
|
||||
const [detailMode, setDetailMode] = useState<'list' | 'detail'>(sharedPreviewTokenSetting ? 'detail' : 'list');
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [saveErrorMessage, setSaveErrorMessage] = useState('');
|
||||
const [saveSuccessMessage, setSaveSuccessMessage] = useState('');
|
||||
const [activeDetailTab, setActiveDetailTab] = useState<'basic' | 'quota' | 'apps'>('basic');
|
||||
const [form] = Form.useForm<TokenSettingFormValue>();
|
||||
const [modalApi, modalContextHolder] = Modal.useModal();
|
||||
const lastHydratedFormKeyRef = useRef('');
|
||||
|
||||
const selectedTokenSetting = useMemo(
|
||||
() => tokenSettings.find((item) => item.id === selectedTokenSettingId) ?? null,
|
||||
[selectedTokenSettingId, tokenSettings],
|
||||
);
|
||||
const effectiveSelectedTokenSetting =
|
||||
selectedTokenSetting ?? (isSharedPreviewMode || isSharedManageMode ? sharedPreviewRecord : null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isSharedPreviewMode || isSharedManageMode) {
|
||||
setSelectedTokenSettingId(sharedPreviewTokenSetting?.id ?? null);
|
||||
if (detailMode !== 'detail') {
|
||||
setDetailMode('detail');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedTokenSettingId && tokenSettings.some((item) => item.id === selectedTokenSettingId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedTokenSettingId(tokenSettings[0]?.id ?? null);
|
||||
}, [detailMode, isSharedManageMode, isSharedPreviewMode, selectedTokenSettingId, sharedPreviewTokenSetting?.id, tokenSettings]);
|
||||
|
||||
useEffect(() => {
|
||||
if (detailMode !== 'detail') {
|
||||
lastHydratedFormKeyRef.current = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const nextFormKey = isCreating ? '__create__' : effectiveSelectedTokenSetting?.id ?? '__empty__';
|
||||
|
||||
if (lastHydratedFormKeyRef.current === nextFormKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastHydratedFormKeyRef.current = nextFormKey;
|
||||
form.resetFields();
|
||||
form.setFieldsValue(toFormValue(isCreating ? null : effectiveSelectedTokenSetting));
|
||||
}, [detailMode, effectiveSelectedTokenSetting?.id, form, isCreating]);
|
||||
|
||||
const openCreateForm = () => {
|
||||
setIsCreating(true);
|
||||
setSelectedTokenSettingId(null);
|
||||
setDetailMode('detail');
|
||||
setSaveErrorMessage('');
|
||||
setSaveSuccessMessage('');
|
||||
setActiveDetailTab('basic');
|
||||
form.resetFields();
|
||||
form.setFieldsValue(EMPTY_FORM_VALUE);
|
||||
};
|
||||
|
||||
const openDetail = (tokenSettingId: string) => {
|
||||
setIsCreating(false);
|
||||
setSelectedTokenSettingId(tokenSettingId);
|
||||
setDetailMode('detail');
|
||||
setSaveErrorMessage('');
|
||||
setSaveSuccessMessage('');
|
||||
setActiveDetailTab('basic');
|
||||
};
|
||||
|
||||
const closeDetail = () => {
|
||||
setIsCreating(false);
|
||||
setDetailMode('list');
|
||||
setSaveErrorMessage('');
|
||||
setSaveSuccessMessage('');
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!selectedTokenSetting) {
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmed = await confirmWithKeyboard(modalApi, {
|
||||
title: `"${selectedTokenSetting.name}" 토큰 설정을 삭제할까요?`,
|
||||
okText: '삭제',
|
||||
cancelText: '취소',
|
||||
okButtonProps: { danger: true },
|
||||
});
|
||||
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextTokenSettings = deleteTokenSetting(tokenSettings, selectedTokenSetting.id);
|
||||
setIsSaving(true);
|
||||
setSaveErrorMessage('');
|
||||
setSaveSuccessMessage('');
|
||||
|
||||
try {
|
||||
const savedTokenSettings = await setTokenSettings(nextTokenSettings);
|
||||
setSelectedTokenSettingId(savedTokenSettings[0]?.id ?? null);
|
||||
setIsCreating(false);
|
||||
setDetailMode('list');
|
||||
form.resetFields();
|
||||
form.setFieldsValue(EMPTY_FORM_VALUE);
|
||||
message.success('토큰 설정을 삭제했습니다.');
|
||||
} catch (error) {
|
||||
setSaveErrorMessage(error instanceof Error ? error.message : '토큰 설정 삭제에 실패했습니다.');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!hasAccess) {
|
||||
if (!isSharedPreviewMode && !isSharedManageMode) {
|
||||
return (
|
||||
<>
|
||||
{modalContextHolder}
|
||||
<Card title="토큰 설정" className="chat-type-management-page">
|
||||
<Alert
|
||||
showIcon
|
||||
type="warning"
|
||||
message="관리 페이지는 토큰 등록 사용자만 사용할 수 있습니다."
|
||||
description="설정 > 토큰 관리에서 권한 토큰을 등록한 뒤 토큰 설정을 관리하세요."
|
||||
/>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`chat-type-management-page${detailMode === 'detail' ? ' chat-type-management-page--detail' : ''}`}>
|
||||
{modalContextHolder}
|
||||
{detailMode === 'list' ? (
|
||||
<Card
|
||||
title="토큰 설정"
|
||||
className="chat-type-management-page__card"
|
||||
extra={
|
||||
isSharedPreviewMode || isSharedManageMode ? null : (
|
||||
<Button icon={<PlusOutlined />} onClick={openCreateForm}>
|
||||
신규 설정
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="chat-type-management-page__list">
|
||||
{errorMessage ? <Alert showIcon type="error" message={errorMessage} /> : null}
|
||||
{saveErrorMessage ? <Alert showIcon type="error" message={saveErrorMessage} /> : null}
|
||||
<div className="chat-type-management-page__list-header">
|
||||
<Title level={5}>등록 토큰 설정</Title>
|
||||
<Text type="secondary">{isLoading ? '불러오는 중' : `${tokenSettings.length}건`}</Text>
|
||||
</div>
|
||||
<Paragraph type="secondary" className="token-setting-management-page__helper">
|
||||
설정 ID를 기준으로 이후 토큰 발급기와 공유 채팅방이 권한, 유효시간, 7일/5시간 사용량 기준을 초기값으로 가져갑니다.
|
||||
</Paragraph>
|
||||
{tokenSettings.length > 0 ? (
|
||||
<List
|
||||
dataSource={tokenSettings}
|
||||
renderItem={(item) => (
|
||||
<List.Item
|
||||
className={
|
||||
item.id === selectedTokenSettingId
|
||||
? 'chat-type-management-page__item chat-type-management-page__item--active'
|
||||
: 'chat-type-management-page__item'
|
||||
}
|
||||
onClick={() => openDetail(item.id)}
|
||||
actions={[
|
||||
<Button
|
||||
key="edit"
|
||||
type="text"
|
||||
icon={<EditOutlined />}
|
||||
disabled={isSaving}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
openDetail(item.id);
|
||||
}}
|
||||
/>,
|
||||
]}
|
||||
>
|
||||
<div className="chat-type-management-page__item-main">
|
||||
<Space size={[8, 8]} wrap>
|
||||
<Text strong>{item.name}</Text>
|
||||
<Text type="secondary">{item.id}</Text>
|
||||
<Tag color={item.enabled ? 'green' : 'default'}>{item.enabled ? '사용' : '중지'}</Tag>
|
||||
</Space>
|
||||
<div className="token-setting-management-page__stats">
|
||||
<Tag>{`유효시간 ${formatDuration(item.defaultExpiresInMinutes)}`}</Tag>
|
||||
<Tag>{formatQuotaSummary(item)}</Tag>
|
||||
<Tag>{`앱 ${item.allowedAppIds.length}개`}</Tag>
|
||||
</div>
|
||||
<div className="chat-type-management-page__item-description">
|
||||
{item.description || '설명 없음'}
|
||||
</div>
|
||||
<Space size={[6, 6]} wrap>
|
||||
{resolveAppLabels(item.allowedAppIds).map((label) => (
|
||||
<Tag key={`${item.id}-${label}`}>{label}</Tag>
|
||||
))}
|
||||
</Space>
|
||||
</div>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<Empty description="등록된 토큰 설정이 없습니다." />
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
<Card
|
||||
title={isCreating ? '토큰 설정 등록' : '토큰 설정 상세'}
|
||||
className="chat-type-management-page__card"
|
||||
extra={
|
||||
isSharedPreviewMode ? (
|
||||
<Tag color="blue">공유 링크</Tag>
|
||||
) : isSharedManageMode ? (
|
||||
<Tag color="cyan">공유 링크 관리</Tag>
|
||||
) : (
|
||||
<Space size={6} className="chat-type-management-page__header-actions" wrap>
|
||||
<Button
|
||||
type="primary"
|
||||
shape="circle"
|
||||
icon={<SaveOutlined />}
|
||||
loading={isSaving}
|
||||
aria-label={isCreating ? '등록' : '수정 저장'}
|
||||
onClick={() => {
|
||||
void form.submit();
|
||||
}}
|
||||
/>
|
||||
<Button shape="circle" icon={<PlusOutlined />} disabled={isSaving} aria-label="새 입력" onClick={openCreateForm} />
|
||||
{!isCreating && selectedTokenSetting ? (
|
||||
<Button
|
||||
danger
|
||||
shape="circle"
|
||||
icon={<DeleteOutlined />}
|
||||
loading={isSaving}
|
||||
aria-label="삭제"
|
||||
onClick={() => void handleDelete()}
|
||||
/>
|
||||
) : null}
|
||||
<Button shape="circle" icon={<UnorderedListOutlined />} aria-label="목록 가기" onClick={closeDetail} />
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="chat-type-management-page__editor">
|
||||
{isSharedPreviewMode ? (
|
||||
<Alert
|
||||
showIcon
|
||||
type="info"
|
||||
message="현재 공유 링크에 연결된 토큰 설정입니다."
|
||||
description="허용된 앱과 한도를 이 화면에서 바로 확인할 수 있습니다."
|
||||
/>
|
||||
) : null}
|
||||
{isSharedManageMode ? (
|
||||
<Alert
|
||||
showIcon
|
||||
type="info"
|
||||
message="현재 공유 링크에 연결된 토큰 설정을 관리 중입니다."
|
||||
description="이 공유 링크에 연결된 설정 1건만 수정할 수 있습니다."
|
||||
/>
|
||||
) : null}
|
||||
{(!isSharedPreviewMode || isSharedManageMode) && errorMessage ? <Alert showIcon type="error" message={errorMessage} /> : null}
|
||||
{saveErrorMessage ? <Alert showIcon type="error" message={saveErrorMessage} /> : null}
|
||||
{saveSuccessMessage ? <Alert showIcon type="success" message={saveSuccessMessage} /> : null}
|
||||
<Form
|
||||
className="chat-type-management-page__editor-form"
|
||||
disabled={isSharedPreviewMode}
|
||||
layout="vertical"
|
||||
form={form}
|
||||
initialValues={EMPTY_FORM_VALUE}
|
||||
scrollToFirstError
|
||||
onFinishFailed={() => {
|
||||
setSaveSuccessMessage('');
|
||||
setSaveErrorMessage('필수 입력값과 권한 앱 선택을 확인해 주세요.');
|
||||
}}
|
||||
onFinish={async (values) => {
|
||||
const nextTokenSettings = upsertTokenSetting(tokenSettings, values);
|
||||
const isNewSetting = isCreating;
|
||||
setIsSaving(true);
|
||||
setSaveErrorMessage('');
|
||||
setSaveSuccessMessage('');
|
||||
|
||||
try {
|
||||
const savedTokenSettings = await setTokenSettings(nextTokenSettings);
|
||||
const normalizedSavedId = normalizeSettingId(values.id);
|
||||
const savedTokenSetting =
|
||||
savedTokenSettings.find((item) => item.id === normalizedSavedId) ??
|
||||
savedTokenSettings.find((item) => item.id === normalizeSettingId(values.originalId));
|
||||
setIsCreating(false);
|
||||
setSelectedTokenSettingId(savedTokenSetting?.id ?? null);
|
||||
setDetailMode('detail');
|
||||
const nextSuccessMessage = isNewSetting ? '토큰 설정을 등록했습니다.' : '토큰 설정을 저장했습니다.';
|
||||
setSaveSuccessMessage(nextSuccessMessage);
|
||||
message.success(nextSuccessMessage);
|
||||
} catch (error) {
|
||||
setSaveErrorMessage(error instanceof Error ? error.message : '토큰 설정 저장에 실패했습니다.');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Form.Item name="originalId" hidden>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<div className="chat-type-management-page__editor-scroll">
|
||||
<Tabs
|
||||
activeKey={activeDetailTab}
|
||||
onChange={(key) => setActiveDetailTab(key as 'basic' | 'quota' | 'apps')}
|
||||
className="token-setting-management-page__detail-tabs"
|
||||
items={[
|
||||
{
|
||||
key: 'basic',
|
||||
label: '기본 정보',
|
||||
children: (
|
||||
<div className="token-setting-management-page__section-scroll">
|
||||
{isSharedPreviewMode && sharedPreviewRecord ? (
|
||||
<Descriptions column={1} bordered size="small" className="token-setting-management-page__summary-descriptions">
|
||||
<Descriptions.Item label="설정 ID">{sharedPreviewRecord.id}</Descriptions.Item>
|
||||
<Descriptions.Item label="설정명">{sharedPreviewRecord.name}</Descriptions.Item>
|
||||
<Descriptions.Item label="유효시간">{formatDuration(sharedPreviewRecord.defaultExpiresInMinutes)}</Descriptions.Item>
|
||||
<Descriptions.Item label="앱 개수">{`${sharedPreviewRecord.allowedAppIds.length}개`}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
) : (
|
||||
<div className="chat-type-management-page__meta-grid">
|
||||
<Form.Item
|
||||
className="chat-type-management-page__meta-item"
|
||||
label="설정 ID"
|
||||
name="id"
|
||||
extra={isSharedManageMode ? '공유 링크 관리 모드에서는 설정 ID를 변경할 수 없습니다.' : undefined}
|
||||
rules={[
|
||||
{ required: true, message: '설정 ID를 입력하세요.' },
|
||||
{
|
||||
validator: async (_rule, value) => {
|
||||
const normalized = normalizeSettingId(value);
|
||||
|
||||
if (!normalized) {
|
||||
throw new Error('영문, 숫자, `-`, `_`, `.` 조합으로 입력하세요.');
|
||||
}
|
||||
},
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input placeholder="예: photoprism-basic" disabled={isSharedManageMode} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
className="chat-type-management-page__meta-item chat-type-management-page__meta-item--enabled"
|
||||
label="사용"
|
||||
name="enabled"
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch checkedChildren="사용" unCheckedChildren="중지" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
className="chat-type-management-page__meta-item chat-type-management-page__meta-item--name"
|
||||
label="설정명"
|
||||
name="name"
|
||||
rules={[{ required: true, message: '설정명을 입력하세요.' }]}
|
||||
>
|
||||
<Input placeholder="예: PhotoPrism 읽기 전용" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
className="chat-type-management-page__meta-item chat-type-management-page__meta-item--name"
|
||||
label="설명"
|
||||
name="description"
|
||||
>
|
||||
<Input.TextArea
|
||||
autoSize={{ minRows: 4, maxRows: 10 }}
|
||||
placeholder="이 설정으로 발급할 토큰의 용도와 제한을 적어 두세요."
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'quota',
|
||||
label: '만료·한도',
|
||||
children: (
|
||||
<div className="token-setting-management-page__section-scroll">
|
||||
<div className="token-setting-management-page__quota-grid">
|
||||
<Form.Item
|
||||
label="유효시간(분)"
|
||||
name="defaultExpiresInMinutes"
|
||||
extra="0이면 만료 없이 계속 사용할 수 있습니다."
|
||||
rules={[{ required: true, message: '기본 유효시간을 입력하세요.' }]}
|
||||
>
|
||||
<InputNumber
|
||||
min={0}
|
||||
style={{ width: '100%' }}
|
||||
formatter={(value) => formatUnlimitedNumberInput(value, '분')}
|
||||
parser={((value: string | undefined) => Number(parseUnlimitedNumberInput(value) || 0)) as unknown as (displayValue: string | undefined) => 0}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="7일 사용 가능 토큰"
|
||||
name="maxTokensPer7Days"
|
||||
extra="최근 7일 동안 누적 사용할 수 있는 총 토큰입니다."
|
||||
rules={[{ required: true, message: '7일 토큰 한도를 입력하세요.' }]}
|
||||
>
|
||||
<InputNumber
|
||||
min={0}
|
||||
style={{ width: '100%' }}
|
||||
formatter={(value) => formatUnlimitedNumberInput(value, '토큰')}
|
||||
parser={((value: string | undefined) => Number(parseUnlimitedNumberInput(value) || 0)) as unknown as (displayValue: string | undefined) => 0}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="5시간 사용 가능 토큰"
|
||||
name="maxTokensPer5Hours"
|
||||
extra="최근 5시간 동안 누적 사용할 수 있는 총 토큰입니다."
|
||||
rules={[{ required: true, message: '5시간 토큰 한도를 입력하세요.' }]}
|
||||
>
|
||||
<InputNumber
|
||||
min={0}
|
||||
style={{ width: '100%' }}
|
||||
formatter={(value) => formatUnlimitedNumberInput(value, '토큰')}
|
||||
parser={((value: string | undefined) => Number(parseUnlimitedNumberInput(value) || 0)) as unknown as (displayValue: string | undefined) => 0}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'apps',
|
||||
label: `앱 권한 ${APP_OPTIONS.length}`,
|
||||
children: (
|
||||
<div className="token-setting-management-page__section-scroll">
|
||||
<Form.Item
|
||||
label="실행 가능 앱"
|
||||
name="allowedAppIds"
|
||||
rules={[
|
||||
{
|
||||
validator: async (_rule, value) => {
|
||||
if (Array.isArray(value) && value.length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error('최소 1개 이상의 앱 권한을 선택하세요.');
|
||||
},
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Checkbox.Group style={{ width: '100%' }}>
|
||||
<div className="token-setting-management-page__app-grid">
|
||||
{APP_OPTIONS.map((option) => (
|
||||
<div key={option.value} className="token-setting-management-page__app-card">
|
||||
<div className="token-setting-management-page__app-card-header">
|
||||
<div className="token-setting-management-page__app-card-title">
|
||||
<Text strong>{option.label}</Text>
|
||||
<Text type="secondary">{option.value}</Text>
|
||||
</div>
|
||||
<Tag>{option.category}</Tag>
|
||||
</div>
|
||||
<Text type="secondary">{option.description}</Text>
|
||||
<Checkbox value={option.value}>권한 부여</Checkbox>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Checkbox.Group>
|
||||
</Form.Item>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
{isSharedPreviewMode ? (
|
||||
<div className="token-setting-management-page__form-actions">
|
||||
<Text type="secondary">공유 링크에서는 현재 연결된 설정 1건의 상세 정보만 확인할 수 있습니다.</Text>
|
||||
</div>
|
||||
) : isSharedManageMode ? (
|
||||
<div className="token-setting-management-page__form-actions">
|
||||
<Space size={8} wrap>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SaveOutlined />}
|
||||
loading={isSaving}
|
||||
onClick={() => {
|
||||
void form.submit();
|
||||
}}
|
||||
>
|
||||
저장
|
||||
</Button>
|
||||
</Space>
|
||||
<Text type="secondary">현재 공유 링크에 연결된 토큰 설정 1건만 수정됩니다.</Text>
|
||||
</div>
|
||||
) : (
|
||||
<div className="token-setting-management-page__form-actions">
|
||||
<Space size={8} wrap>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SaveOutlined />}
|
||||
loading={isSaving}
|
||||
onClick={() => {
|
||||
void form.submit();
|
||||
}}
|
||||
>
|
||||
{isCreating ? '등록' : '저장'}
|
||||
</Button>
|
||||
<Button icon={<PlusOutlined />} disabled={isSaving} onClick={openCreateForm}>
|
||||
새 입력
|
||||
</Button>
|
||||
{!isCreating && selectedTokenSetting ? (
|
||||
<Button danger icon={<DeleteOutlined />} loading={isSaving} onClick={() => void handleDelete()}>
|
||||
삭제
|
||||
</Button>
|
||||
) : null}
|
||||
<Button icon={<UnorderedListOutlined />} onClick={closeDetail}>
|
||||
목록
|
||||
</Button>
|
||||
</Space>
|
||||
<Text type="secondary">저장 후 현재 상세 화면을 유지한 채 결과 메시지를 바로 보여줍니다.</Text>
|
||||
</div>
|
||||
)}
|
||||
</Form>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user