Files
new-api/web/src/components/table/tokens/index.jsx
t0ng7u adc7fbd424 ♻️ refactor(web): migrate React modules from .js to .jsx and align entrypoint
- Rename React components/pages/utilities that contain JSX to `.jsx` across `web/src`
- Update import paths and re-exports to match new `.jsx` extensions
- Fix Vite entry by switching `web/index.html` from `/src/index.js` to `/src/index.jsx`
- Verified remaining `.js` files are plain JS (hooks/helpers/constants) and do not require JSX
- No runtime behavior changes; extension and reference alignment only

Context: Resolves the Vite pre-transform error caused by the stale `/src/index.js` entry after migrating to `.jsx`.
2025-08-18 04:14:35 +08:00

362 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React, { useEffect, useRef, useState } from 'react';
import { Notification, Button, Space, Toast, Typography, Select } from '@douyinfe/semi-ui';
import { API, showError, getModelCategories, selectFilter } from '../../../helpers';
import CardPro from '../../common/ui/CardPro';
import TokensTable from './TokensTable';
import TokensActions from './TokensActions';
import TokensFilters from './TokensFilters';
import TokensDescription from './TokensDescription';
import EditTokenModal from './modals/EditTokenModal';
import { useTokensData } from '../../../hooks/tokens/useTokensData';
import { useIsMobile } from '../../../hooks/common/useIsMobile';
import { createCardProPagination } from '../../../helpers/utils';
function TokensPage() {
// Define the function first, then pass it into the hook to avoid TDZ errors
const openFluentNotificationRef = useRef(null);
const tokensData = useTokensData((key) => openFluentNotificationRef.current?.(key));
const isMobile = useIsMobile();
const latestRef = useRef({ tokens: [], selectedKeys: [], t: (k) => k, selectedModel: '', prefillKey: '' });
const [modelOptions, setModelOptions] = useState([]);
const [selectedModel, setSelectedModel] = useState('');
const [fluentNoticeOpen, setFluentNoticeOpen] = useState(false);
const [prefillKey, setPrefillKey] = useState('');
// Keep latest data for handlers inside notifications
useEffect(() => {
latestRef.current = {
tokens: tokensData.tokens,
selectedKeys: tokensData.selectedKeys,
t: tokensData.t,
selectedModel,
prefillKey,
};
}, [tokensData.tokens, tokensData.selectedKeys, tokensData.t, selectedModel, prefillKey]);
const loadModels = async () => {
try {
const res = await API.get('/api/user/models');
const { success, message, data } = res.data || {};
if (success) {
const categories = getModelCategories(tokensData.t);
const options = (data || []).map((model) => {
let icon = null;
for (const [key, category] of Object.entries(categories)) {
if (key !== 'all' && category.filter({ model_name: model })) {
icon = category.icon;
break;
}
}
return {
label: (
<span className="flex items-center gap-1">
{icon}
{model}
</span>
),
value: model,
};
});
setModelOptions(options);
} else {
showError(tokensData.t(message));
}
} catch (e) {
showError(e.message || 'Failed to load models');
}
};
function openFluentNotification(key) {
const { t } = latestRef.current;
const SUPPRESS_KEY = 'fluent_notify_suppressed';
if (modelOptions.length === 0) {
// fire-and-forget; a later effect will refresh the notice content
loadModels()
}
if (!key && localStorage.getItem(SUPPRESS_KEY) === '1') return;
const container = document.getElementById('fluent-new-api-container');
if (!container) {
Toast.warning(t('未检测到 FluentRead流畅阅读请确认扩展已启用'));
return;
}
setPrefillKey(key || '');
setFluentNoticeOpen(true);
Notification.info({
id: 'fluent-detected',
title: t('检测到 FluentRead流畅阅读'),
content: (
<div>
<div style={{ marginBottom: 8 }}>
{key
? t('请选择模型。')
: t('选择模型后可一键填充当前选中令牌(或本页第一个令牌)。')}
</div>
<div style={{ marginBottom: 8 }}>
<Select
placeholder={t('请选择模型')}
optionList={modelOptions}
onChange={setSelectedModel}
filter={selectFilter}
style={{ width: 320 }}
showClear
searchable
emptyContent={t('暂无数据')}
/>
</div>
<Space>
<Button theme="solid" type="primary" onClick={handlePrefillToFluent}>
{t('一键填充到 FluentRead')}
</Button>
{!key && (
<Button type="warning" onClick={() => {
localStorage.setItem(SUPPRESS_KEY, '1');
Notification.close('fluent-detected');
Toast.info(t('已关闭后续提醒'));
}}>
{t('不再提醒')}
</Button>
)}
<Button type="tertiary" onClick={() => Notification.close('fluent-detected')}>
{t('关闭')}
</Button>
</Space>
</div>
),
duration: 0,
});
}
// assign after definition so hook callback can call it safely
openFluentNotificationRef.current = openFluentNotification;
// Prefill to Fluent handler
const handlePrefillToFluent = () => {
const { tokens, selectedKeys, t, selectedModel: chosenModel, prefillKey: overrideKey } = latestRef.current;
const container = document.getElementById('fluent-new-api-container');
if (!container) {
Toast.error(t('未检测到 Fluent 容器'));
return;
}
if (!chosenModel) {
Toast.warning(t('请选择模型'));
return;
}
let status = localStorage.getItem('status');
let serverAddress = '';
if (status) {
try {
status = JSON.parse(status);
serverAddress = status.server_address || '';
} catch (_) { }
}
if (!serverAddress) serverAddress = window.location.origin;
let apiKeyToUse = '';
if (overrideKey) {
apiKeyToUse = 'sk-' + overrideKey;
} else {
const token = (selectedKeys && selectedKeys.length === 1)
? selectedKeys[0]
: (tokens && tokens.length > 0 ? tokens[0] : null);
if (!token) {
Toast.warning(t('没有可用令牌用于填充'));
return;
}
apiKeyToUse = 'sk-' + token.key;
}
const payload = {
id: 'new-api',
baseUrl: serverAddress,
apiKey: apiKeyToUse,
model: chosenModel,
};
container.dispatchEvent(new CustomEvent('fluent:prefill', { detail: payload }));
Toast.success(t('已发送到 Fluent'));
Notification.close('fluent-detected');
};
// Show notification when Fluent container is available
useEffect(() => {
const onAppeared = () => {
openFluentNotification();
};
const onRemoved = () => {
setFluentNoticeOpen(false);
Notification.close('fluent-detected');
};
window.addEventListener('fluent-container:appeared', onAppeared);
window.addEventListener('fluent-container:removed', onRemoved);
return () => {
window.removeEventListener('fluent-container:appeared', onAppeared);
window.removeEventListener('fluent-container:removed', onRemoved);
};
}, []);
// When modelOptions or language changes while the notice is open, refresh the content
useEffect(() => {
if (fluentNoticeOpen) {
openFluentNotification();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [modelOptions, selectedModel, tokensData.t, fluentNoticeOpen]);
useEffect(() => {
const selector = '#fluent-new-api-container';
const root = document.body || document.documentElement;
const existing = document.querySelector(selector);
if (existing) {
console.log('Fluent container detected (initial):', existing);
window.dispatchEvent(new CustomEvent('fluent-container:appeared', { detail: existing }));
}
const isOrContainsTarget = (node) => {
if (!(node && node.nodeType === 1)) return false;
if (node.id === 'fluent-new-api-container') return true;
return typeof node.querySelector === 'function' && !!node.querySelector(selector);
};
const observer = new MutationObserver((mutations) => {
for (const m of mutations) {
// appeared
for (const added of m.addedNodes) {
if (isOrContainsTarget(added)) {
const el = document.querySelector(selector);
if (el) {
console.log('Fluent container appeared:', el);
window.dispatchEvent(new CustomEvent('fluent-container:appeared', { detail: el }));
}
break;
}
}
// removed
for (const removed of m.removedNodes) {
if (isOrContainsTarget(removed)) {
const elNow = document.querySelector(selector);
if (!elNow) {
console.log('Fluent container removed');
window.dispatchEvent(new CustomEvent('fluent-container:removed'));
}
break;
}
}
}
});
observer.observe(root, { childList: true, subtree: true });
return () => observer.disconnect();
}, []);
const {
// Edit state
showEdit,
editingToken,
closeEdit,
refresh,
// Actions state
selectedKeys,
setEditingToken,
setShowEdit,
batchCopyTokens,
batchDeleteTokens,
copyText,
// Filters state
formInitValues,
setFormApi,
searchTokens,
loading,
searching,
// Description state
compactMode,
setCompactMode,
// Translation
t,
} = tokensData;
return (
<>
<EditTokenModal
refresh={refresh}
editingToken={editingToken}
visiable={showEdit}
handleClose={closeEdit}
/>
<CardPro
type="type1"
descriptionArea={
<TokensDescription
compactMode={compactMode}
setCompactMode={setCompactMode}
t={t}
/>
}
actionsArea={
<div className="flex flex-col md:flex-row justify-between items-center gap-2 w-full">
<TokensActions
selectedKeys={selectedKeys}
setEditingToken={setEditingToken}
setShowEdit={setShowEdit}
batchCopyTokens={batchCopyTokens}
batchDeleteTokens={batchDeleteTokens}
copyText={copyText}
t={t}
/>
<div className="w-full md:w-full lg:w-auto order-1 md:order-2">
<TokensFilters
formInitValues={formInitValues}
setFormApi={setFormApi}
searchTokens={searchTokens}
loading={loading}
searching={searching}
t={t}
/>
</div>
</div>
}
paginationArea={createCardProPagination({
currentPage: tokensData.activePage,
pageSize: tokensData.pageSize,
total: tokensData.tokenCount,
onPageChange: tokensData.handlePageChange,
onPageSizeChange: tokensData.handlePageSizeChange,
isMobile: isMobile,
t: tokensData.t,
})}
t={tokensData.t}
>
<TokensTable {...tokensData} />
</CardPro>
</>
);
}
export default TokensPage;