mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-06-16 21:03:58 +08:00
Compare commits
10 Commits
@@ -229,6 +229,90 @@
|
||||
@media (max-width: 1024px) {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
@include mobile {
|
||||
gap: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.mobileSectionNav {
|
||||
display: none;
|
||||
|
||||
@include mobile {
|
||||
position: sticky;
|
||||
top: calc(var(--header-height, 64px) + 12px);
|
||||
z-index: 4;
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.mobileSectionNavScroller {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
overflow-x: auto;
|
||||
padding: 4px 2px 10px;
|
||||
scrollbar-width: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.mobileSectionNavButton {
|
||||
@include button-reset;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: max-content;
|
||||
flex: 0 0 auto;
|
||||
padding: 10px 14px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 84%, transparent);
|
||||
background: color-mix(in srgb, var(--bg-primary) 88%, transparent);
|
||||
box-shadow: 0 18px 36px -30px rgba(0, 0, 0, 0.28);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mobileSectionNavButtonActive {
|
||||
border-color: color-mix(in srgb, var(--primary-color) 24%, var(--border-color));
|
||||
background: color-mix(in srgb, var(--bg-primary) 96%, transparent);
|
||||
}
|
||||
|
||||
.mobileSectionNavIndex {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 28px;
|
||||
height: 28px;
|
||||
padding: 0 8px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--bg-secondary) 88%, transparent);
|
||||
color: var(--text-secondary);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.mobileSectionNavLabel {
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.mobileSectionNavBadge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 22px;
|
||||
height: 22px;
|
||||
padding: 0 6px;
|
||||
border-radius: 999px;
|
||||
background: var(--warning-bg);
|
||||
border: 1px solid var(--warning-border);
|
||||
color: var(--warning-text);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
@@ -447,6 +531,10 @@
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 16px;
|
||||
|
||||
@include mobile {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.sectionStack {
|
||||
@@ -729,9 +817,19 @@
|
||||
}
|
||||
|
||||
@include mobile {
|
||||
.overview {
|
||||
gap: 14px;
|
||||
padding: 18px;
|
||||
border-radius: 24px;
|
||||
}
|
||||
|
||||
.overviewFocusLink {
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.sections {
|
||||
border-radius: 26px;
|
||||
padding: 18px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.subsection {
|
||||
@@ -739,7 +837,41 @@
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.toggleRow {
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.blockHeaderRow,
|
||||
.ruleCardHeader {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.blockHeaderRow :global(.btn),
|
||||
.ruleCardHeader :global(.btn),
|
||||
.actionRow :global(.btn),
|
||||
.stringListRow :global(.btn) {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.actionRow {
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
.stringListRow {
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 380px) {
|
||||
.overview,
|
||||
.sections {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.subsection,
|
||||
.ruleCard,
|
||||
.toggleRow {
|
||||
padding: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,6 +196,10 @@ export function VisualConfigEditor({
|
||||
const sidebarAnchorRef = useRef<HTMLElement | null>(null);
|
||||
const floatingSidebarRef = useRef<HTMLDivElement | null>(null);
|
||||
const sectionRefs = useRef<Partial<Record<VisualSectionId, HTMLElement | null>>>({});
|
||||
const mobileNavScrollerRef = useRef<HTMLDivElement | null>(null);
|
||||
const mobileNavButtonRefs = useRef<Partial<Record<VisualSectionId, HTMLButtonElement | null>>>(
|
||||
{}
|
||||
);
|
||||
|
||||
const isKeepaliveDisabled =
|
||||
values.streaming.keepaliveSeconds === '' || values.streaming.keepaliveSeconds === '0';
|
||||
@@ -355,6 +359,27 @@ export function VisualConfigEditor({
|
||||
return () => observer.disconnect();
|
||||
}, [sections]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMobile) return;
|
||||
const scroller = mobileNavScrollerRef.current;
|
||||
const button = mobileNavButtonRefs.current[activeSectionId];
|
||||
if (!scroller || !button) return;
|
||||
|
||||
const scrollerRect = scroller.getBoundingClientRect();
|
||||
const buttonRect = button.getBoundingClientRect();
|
||||
const centeredLeft =
|
||||
scroller.scrollLeft +
|
||||
(buttonRect.left - scrollerRect.left) -
|
||||
(scroller.clientWidth - buttonRect.width) / 2;
|
||||
const maxScrollLeft = Math.max(scroller.scrollWidth - scroller.clientWidth, 0);
|
||||
const targetLeft = Math.min(Math.max(centeredLeft, 0), maxScrollLeft);
|
||||
|
||||
scroller.scrollTo({
|
||||
left: targetLeft,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}, [activeSectionId, isMobile]);
|
||||
|
||||
const handleSectionJump = useCallback((sectionId: VisualSectionId) => {
|
||||
setActiveSectionId(sectionId);
|
||||
sectionRefs.current[sectionId]?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
@@ -532,6 +557,40 @@ export function VisualConfigEditor({
|
||||
</div>
|
||||
|
||||
<div ref={workspaceRef} className={styles.workspace}>
|
||||
{isMobile ? (
|
||||
<div className={styles.mobileSectionNav}>
|
||||
<div
|
||||
ref={mobileNavScrollerRef}
|
||||
className={styles.mobileSectionNavScroller}
|
||||
aria-label={t('config_management.visual.quick_jump', { defaultValue: '快速跳转' })}
|
||||
>
|
||||
{sections.map((section, index) => (
|
||||
<button
|
||||
key={section.id}
|
||||
ref={(node) => {
|
||||
mobileNavButtonRefs.current[section.id] = node;
|
||||
}}
|
||||
type="button"
|
||||
className={`${styles.mobileSectionNavButton} ${
|
||||
activeSectionId === section.id ? styles.mobileSectionNavButtonActive : ''
|
||||
}`}
|
||||
onClick={() => handleSectionJump(section.id)}
|
||||
>
|
||||
<span className={styles.mobileSectionNavIndex}>
|
||||
{String(index + 1).padStart(2, '0')}
|
||||
</span>
|
||||
<span className={styles.mobileSectionNavLabel}>{section.title}</span>
|
||||
{section.errorCount > 0 ? (
|
||||
<span className={styles.mobileSectionNavBadge} aria-hidden="true">
|
||||
{section.errorCount}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<aside ref={sidebarAnchorRef} className={styles.sidebar}>
|
||||
{isFloatingSidebar ? (
|
||||
<div className={styles.sidebarPlaceholder} aria-hidden="true" />
|
||||
|
||||
@@ -277,7 +277,7 @@ export function Modal({
|
||||
<div
|
||||
ref={modalRef}
|
||||
className={modalClass}
|
||||
style={{ width }}
|
||||
style={{ width, maxWidth: '100%' }}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={title ? titleId : undefined}
|
||||
|
||||
@@ -1133,16 +1133,24 @@
|
||||
"description": "Edit config.yaml via visual editor or source file",
|
||||
"status_idle": "Waiting for action",
|
||||
"status_loading": "Loading configuration...",
|
||||
"status_loading_short": "Loading",
|
||||
"status_loaded": "Configuration loaded",
|
||||
"status_loaded_short": "Loaded",
|
||||
"status_dirty": "Unsaved changes",
|
||||
"status_dirty_short": "Unsaved",
|
||||
"status_disconnected": "Connect to the server to load the configuration",
|
||||
"status_disconnected_short": "Disconnected",
|
||||
"status_load_failed": "Load failed",
|
||||
"status_load_failed_short": "Failed",
|
||||
"status_saving": "Saving configuration...",
|
||||
"status_saving_short": "Saving",
|
||||
"status_saved": "Configuration saved",
|
||||
"status_save_failed": "Save failed",
|
||||
"save_success": "Configuration saved successfully",
|
||||
"error_yaml_not_supported": "Server did not return YAML. Verify the /config.yaml endpoint is available.",
|
||||
"visual_mode_unavailable": "Visual editor unavailable until YAML syntax is fixed",
|
||||
"visual_mode_unavailable_short": "YAML issue",
|
||||
"validation_blocked_short": "Fix errors",
|
||||
"visual_mode_unavailable_detail": "Visual editor is unavailable because the configuration contains invalid YAML: {{message}}",
|
||||
"visual_mode_save_blocked": "Cannot save from visual mode until the YAML syntax is fixed",
|
||||
"visual_mode_latest_yaml_invalid": "The latest server configuration contains invalid YAML. Review it in source mode before saving visual changes: {{message}}",
|
||||
|
||||
@@ -1136,16 +1136,24 @@
|
||||
"description": "Редактируйте config.yaml через визуальный редактор или исходный файл",
|
||||
"status_idle": "Ожидание действия",
|
||||
"status_loading": "Загрузка конфигурации...",
|
||||
"status_loading_short": "Загрузка",
|
||||
"status_loaded": "Конфигурация загружена",
|
||||
"status_loaded_short": "Загружено",
|
||||
"status_dirty": "Есть несохранённые изменения",
|
||||
"status_dirty_short": "Несохранено",
|
||||
"status_disconnected": "Подключитесь к серверу, чтобы загрузить конфигурацию",
|
||||
"status_disconnected_short": "Нет связи",
|
||||
"status_load_failed": "Не удалось загрузить",
|
||||
"status_load_failed_short": "Ошибка",
|
||||
"status_saving": "Сохранение конфигурации...",
|
||||
"status_saving_short": "Сохранение",
|
||||
"status_saved": "Конфигурация сохранена",
|
||||
"status_save_failed": "Не удалось сохранить",
|
||||
"save_success": "Конфигурация успешно сохранена",
|
||||
"error_yaml_not_supported": "Сервер не вернул YAML. Убедитесь, что доступна конечная точка /config.yaml.",
|
||||
"visual_mode_unavailable": "Визуальный редактор недоступен, пока не исправлен синтаксис YAML",
|
||||
"visual_mode_unavailable_short": "Ошибка YAML",
|
||||
"validation_blocked_short": "Есть ошибки",
|
||||
"visual_mode_unavailable_detail": "Визуальный редактор недоступен, потому что в конфигурации есть некорректный YAML: {{message}}",
|
||||
"visual_mode_save_blocked": "Нельзя сохранять из визуального режима, пока не исправлен синтаксис YAML",
|
||||
"visual_mode_latest_yaml_invalid": "Последняя конфигурация на сервере содержит некорректный YAML. Проверьте её в режиме исходника перед сохранением визуальных изменений: {{message}}",
|
||||
|
||||
@@ -1133,16 +1133,24 @@
|
||||
"description": "通过可视化或者源文件方式编辑 config.yaml 配置文件",
|
||||
"status_idle": "等待操作",
|
||||
"status_loading": "加载配置中...",
|
||||
"status_loading_short": "加载中",
|
||||
"status_loaded": "配置已加载",
|
||||
"status_loaded_short": "已加载",
|
||||
"status_dirty": "有未保存的更改",
|
||||
"status_dirty_short": "未保存",
|
||||
"status_disconnected": "请先连接服务器以加载配置",
|
||||
"status_disconnected_short": "未连接",
|
||||
"status_load_failed": "加载失败",
|
||||
"status_load_failed_short": "失败",
|
||||
"status_saving": "正在保存配置...",
|
||||
"status_saving_short": "保存中",
|
||||
"status_saved": "配置保存完成",
|
||||
"status_save_failed": "保存失败",
|
||||
"save_success": "配置已保存",
|
||||
"error_yaml_not_supported": "服务器未返回 YAML 格式,请确认 /config.yaml 接口可用",
|
||||
"visual_mode_unavailable": "YAML 语法修复前无法使用可视化编辑",
|
||||
"visual_mode_unavailable_short": "YAML错误",
|
||||
"validation_blocked_short": "待修复",
|
||||
"visual_mode_unavailable_detail": "当前配置存在无效 YAML,暂时无法使用可视化编辑:{{message}}",
|
||||
"visual_mode_save_blocked": "请先修复 YAML 语法错误,再从可视化模式保存",
|
||||
"visual_mode_latest_yaml_invalid": "服务端最新配置包含无效 YAML,请先切回源码模式检查后再保存可视化修改:{{message}}",
|
||||
|
||||
@@ -12,7 +12,7 @@ import { useEdgeSwipeBack } from '@/hooks/useEdgeSwipeBack';
|
||||
import { useNotificationStore } from '@/stores';
|
||||
import { apiCallApi, getApiCallErrorMessage } from '@/services/api';
|
||||
import type { ApiKeyEntry } from '@/types';
|
||||
import { buildHeaderObject } from '@/utils/headers';
|
||||
import { buildHeaderObject, hasHeader } from '@/utils/headers';
|
||||
import { buildApiKeyEntry, buildOpenAIChatCompletionsEndpoint } from '@/components/providers/utils';
|
||||
import type { OpenAIEditOutletContext } from './AiProvidersOpenAIEditLayout';
|
||||
import type { KeyTestStatus } from '@/stores/useOpenAIEditDraftStore';
|
||||
@@ -213,7 +213,7 @@ export function AiProvidersOpenAIEditPage() {
|
||||
'Content-Type': 'application/json',
|
||||
...customHeaders,
|
||||
};
|
||||
if (!headers.Authorization && !headers['authorization']) {
|
||||
if (!hasHeader(headers, 'authorization')) {
|
||||
headers.Authorization = `Bearer ${keyEntry.apiKey.trim()}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import { SecondaryScreenShell } from '@/components/common/SecondaryScreenShell';
|
||||
import { useEdgeSwipeBack } from '@/hooks/useEdgeSwipeBack';
|
||||
import { modelsApi } from '@/services/api';
|
||||
import type { ModelInfo } from '@/utils/models';
|
||||
import { buildHeaderObject } from '@/utils/headers';
|
||||
import { buildHeaderObject, hasHeader } from '@/utils/headers';
|
||||
import { buildOpenAIModelsEndpoint } from '@/components/providers/utils';
|
||||
import type { OpenAIEditOutletContext } from './AiProvidersOpenAIEditLayout';
|
||||
import styles from './AiProvidersPage.module.scss';
|
||||
@@ -68,7 +68,7 @@ export function AiProvidersOpenAIModelsPage() {
|
||||
try {
|
||||
const headerObject = buildHeaderObject(form.headers);
|
||||
const firstKey = form.apiKeyEntries.find((entry) => entry.apiKey?.trim())?.apiKey?.trim();
|
||||
const hasAuthHeader = Boolean(headerObject.Authorization || headerObject['authorization']);
|
||||
const hasAuthHeader = hasHeader(headerObject, 'authorization');
|
||||
const list = await modelsApi.fetchModelsViaApiCall(
|
||||
trimmedBaseUrl,
|
||||
hasAuthHeader ? undefined : firstKey,
|
||||
|
||||
@@ -53,6 +53,7 @@
|
||||
@include mobile {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
align-items: stretch;
|
||||
gap: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,6 +192,11 @@
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 82%, transparent);
|
||||
background: color-mix(in srgb, var(--bg-primary) 74%, transparent);
|
||||
box-shadow: var(--shadow);
|
||||
|
||||
@include mobile {
|
||||
padding: 16px;
|
||||
border-radius: 28px;
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
@@ -434,6 +440,7 @@
|
||||
.floatingStatus {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
min-height: 28px;
|
||||
padding: 0 10px;
|
||||
border-radius: 999px;
|
||||
@@ -449,6 +456,12 @@
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.floatingStatusCompact {
|
||||
max-width: 112px;
|
||||
padding: 0 8px;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.floatingActionButton {
|
||||
@include button-reset;
|
||||
position: relative;
|
||||
@@ -503,7 +516,9 @@
|
||||
}
|
||||
|
||||
.floatingStatus {
|
||||
display: none;
|
||||
max-width: min(180px, 40vw);
|
||||
font-size: 10px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.floatingActionButton {
|
||||
@@ -512,3 +527,16 @@
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.floatingStatus {
|
||||
max-width: min(132px, 38vw);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 380px) {
|
||||
.pageHeader,
|
||||
.workspaceShell {
|
||||
padding: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,8 +18,9 @@ import {
|
||||
} from '@/components/ui/icons';
|
||||
import { VisualConfigEditor } from '@/components/config/VisualConfigEditor';
|
||||
import { DiffModal } from '@/components/config/DiffModal';
|
||||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||
import { useVisualConfig } from '@/hooks/useVisualConfig';
|
||||
import { useNotificationStore, useAuthStore, useThemeStore } from '@/stores';
|
||||
import { useNotificationStore, useAuthStore, useThemeStore, useConfigStore } from '@/stores';
|
||||
import { configFileApi } from '@/services/api/configFile';
|
||||
import styles from './ConfigPage.module.scss';
|
||||
|
||||
@@ -43,6 +44,7 @@ export function ConfigPage() {
|
||||
const showConfirmation = useNotificationStore((state) => state.showConfirmation);
|
||||
const connectionStatus = useAuthStore((state) => state.connectionStatus);
|
||||
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
|
||||
const isMobile = useMediaQuery('(max-width: 768px)');
|
||||
|
||||
const {
|
||||
visualValues,
|
||||
@@ -137,6 +139,24 @@ export function ConfigPage() {
|
||||
setServerYaml(latestContent);
|
||||
setMergedYaml(latestContent);
|
||||
loadVisualValuesFromYaml(latestContent);
|
||||
|
||||
// Keep the global config store in sync so sidebar / other pages reflect YAML changes immediately.
|
||||
try {
|
||||
useConfigStore.getState().clearCache();
|
||||
await useConfigStore.getState().fetchConfig(undefined, true);
|
||||
} catch (refreshError: unknown) {
|
||||
const message =
|
||||
refreshError instanceof Error
|
||||
? refreshError.message
|
||||
: typeof refreshError === 'string'
|
||||
? refreshError
|
||||
: '';
|
||||
showNotification(
|
||||
`${t('notification.refresh_failed')}${message ? `: ${message}` : ''}`,
|
||||
'error'
|
||||
);
|
||||
}
|
||||
|
||||
showNotification(t('config_management.save_success'), 'success');
|
||||
if (commercialModeChanged) {
|
||||
showNotification(t('notification.commercial_mode_restart_required'), 'warning');
|
||||
@@ -411,6 +431,21 @@ export function ConfigPage() {
|
||||
return '';
|
||||
};
|
||||
|
||||
const getFloatingStatusText = () => {
|
||||
if (!isMobile) return getStatusText();
|
||||
if (disableControls)
|
||||
return t('config_management.status_disconnected_short', { defaultValue: 'Disconnected' });
|
||||
if (loading) return t('config_management.status_loading_short', { defaultValue: 'Loading' });
|
||||
if (error) return t('config_management.status_load_failed_short', { defaultValue: 'Failed' });
|
||||
if (hasVisualModeError)
|
||||
return t('config_management.visual_mode_unavailable_short', { defaultValue: 'YAML issue' });
|
||||
if (hasVisualValidationErrors)
|
||||
return t('config_management.visual.validation_blocked_short', { defaultValue: 'Fix errors' });
|
||||
if (saving) return t('config_management.status_saving_short', { defaultValue: 'Saving' });
|
||||
if (isDirty) return t('config_management.status_dirty_short', { defaultValue: 'Unsaved' });
|
||||
return t('config_management.status_loaded_short', { defaultValue: 'Loaded' });
|
||||
};
|
||||
|
||||
const handleReload = useCallback(() => {
|
||||
if (!isDirty) {
|
||||
void loadConfig();
|
||||
@@ -432,7 +467,13 @@ export function ConfigPage() {
|
||||
const floatingActions = (
|
||||
<div className={styles.floatingActionContainer} ref={floatingActionsRef}>
|
||||
<div className={styles.floatingActionList}>
|
||||
<div className={`${styles.floatingStatus} ${getStatusClass()}`}>{getStatusText()}</div>
|
||||
<div
|
||||
className={`${styles.floatingStatus} ${
|
||||
isMobile ? styles.floatingStatusCompact : ''
|
||||
} ${getStatusClass()}`}
|
||||
>
|
||||
{getFloatingStatusText()}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.floatingActionButton}
|
||||
|
||||
@@ -90,7 +90,7 @@ export const modelsApi = {
|
||||
}
|
||||
|
||||
const resolvedHeaders = { ...headers };
|
||||
if (apiKey) {
|
||||
if (apiKey && !hasHeader(resolvedHeaders, 'authorization')) {
|
||||
resolvedHeaders.Authorization = `Bearer ${apiKey}`;
|
||||
}
|
||||
|
||||
@@ -116,8 +116,7 @@ export const modelsApi = {
|
||||
}
|
||||
|
||||
const resolvedHeaders = { ...headers };
|
||||
const hasAuthHeader = Boolean(resolvedHeaders.Authorization || resolvedHeaders.authorization);
|
||||
if (apiKey && !hasAuthHeader) {
|
||||
if (apiKey && !hasHeader(resolvedHeaders, 'authorization')) {
|
||||
resolvedHeaders.Authorization = `Bearer ${apiKey}`;
|
||||
}
|
||||
|
||||
@@ -149,8 +148,7 @@ export const modelsApi = {
|
||||
}
|
||||
|
||||
const resolvedHeaders = { ...headers };
|
||||
const hasAuthHeader = Boolean(resolvedHeaders.Authorization || resolvedHeaders.authorization);
|
||||
if (apiKey && !hasAuthHeader) {
|
||||
if (apiKey && !hasHeader(resolvedHeaders, 'authorization')) {
|
||||
resolvedHeaders.Authorization = `Bearer ${apiKey}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,20 +1,27 @@
|
||||
/**
|
||||
* 安全存储服务
|
||||
* 本地存储混淆服务(可逆)
|
||||
* 基于原项目 src/utils/secure-storage.js
|
||||
*
|
||||
* IMPORTANT: 这不是安全边界,仅用于避免“肉眼直读”的轻度混淆。
|
||||
*/
|
||||
|
||||
import { encryptData, decryptData } from '@/utils/encryption';
|
||||
import { obfuscateData, deobfuscateData, isObfuscated } from '@/utils/encryption';
|
||||
|
||||
interface StorageOptions {
|
||||
/**
|
||||
* Whether to obfuscate the stored value. This was historically called `encrypt`,
|
||||
* but the implementation is reversible obfuscation, not cryptographic security.
|
||||
*/
|
||||
obfuscate?: boolean;
|
||||
encrypt?: boolean;
|
||||
}
|
||||
|
||||
class SecureStorageService {
|
||||
class ObfuscatedStorageService {
|
||||
/**
|
||||
* 存储数据
|
||||
*/
|
||||
setItem(key: string, value: unknown, options: StorageOptions = {}): void {
|
||||
const { encrypt = true } = options;
|
||||
const obfuscate = options.obfuscate ?? options.encrypt ?? true;
|
||||
|
||||
if (value === null || value === undefined) {
|
||||
this.removeItem(key);
|
||||
@@ -22,7 +29,7 @@ class SecureStorageService {
|
||||
}
|
||||
|
||||
const stringValue = JSON.stringify(value);
|
||||
const storedValue = encrypt ? encryptData(stringValue) : stringValue;
|
||||
const storedValue = obfuscate ? obfuscateData(stringValue) : stringValue;
|
||||
|
||||
localStorage.setItem(key, storedValue);
|
||||
}
|
||||
@@ -31,20 +38,20 @@ class SecureStorageService {
|
||||
* 获取数据
|
||||
*/
|
||||
getItem<T = unknown>(key: string, options: StorageOptions = {}): T | null {
|
||||
const { encrypt = true } = options;
|
||||
const obfuscate = options.obfuscate ?? options.encrypt ?? true;
|
||||
|
||||
const raw = localStorage.getItem(key);
|
||||
if (raw === null) return null;
|
||||
|
||||
try {
|
||||
const decrypted = encrypt ? decryptData(raw) : raw;
|
||||
const decrypted = obfuscate ? deobfuscateData(raw) : raw;
|
||||
return JSON.parse(decrypted) as T;
|
||||
} catch {
|
||||
// JSON解析失败,尝试兼容旧的纯字符串数据 (非JSON格式)
|
||||
try {
|
||||
// 如果是加密的,尝试解密后直接返回
|
||||
if (encrypt && raw.startsWith('enc::v1::')) {
|
||||
const decrypted = decryptData(raw);
|
||||
if (obfuscate && isObfuscated(raw)) {
|
||||
const decrypted = deobfuscateData(raw);
|
||||
// 解密后如果还不是JSON,返回原始字符串
|
||||
return decrypted as T;
|
||||
}
|
||||
@@ -108,4 +115,6 @@ class SecureStorageService {
|
||||
}
|
||||
}
|
||||
|
||||
export const secureStorage = new SecureStorageService();
|
||||
export const obfuscatedStorage = new ObfuscatedStorageService();
|
||||
// Backward-compatible alias (historically named "secureStorage").
|
||||
export const secureStorage = obfuscatedStorage;
|
||||
|
||||
@@ -7,10 +7,11 @@ import { create } from 'zustand';
|
||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||
import type { AuthState, LoginCredentials, ConnectionStatus } from '@/types';
|
||||
import { STORAGE_KEY_AUTH } from '@/utils/constants';
|
||||
import { secureStorage } from '@/services/storage/secureStorage';
|
||||
import { obfuscatedStorage } from '@/services/storage/secureStorage';
|
||||
import { apiClient } from '@/services/api/client';
|
||||
import { useConfigStore } from './useConfigStore';
|
||||
import { useUsageStatsStore } from './useUsageStatsStore';
|
||||
import { useModelsStore } from './useModelsStore';
|
||||
import { detectApiBaseFromLocation, normalizeApiBase } from '@/utils/connection';
|
||||
|
||||
interface AuthStoreState extends AuthState {
|
||||
@@ -46,13 +47,13 @@ export const useAuthStore = create<AuthStoreState>()(
|
||||
if (restoreSessionPromise) return restoreSessionPromise;
|
||||
|
||||
restoreSessionPromise = (async () => {
|
||||
secureStorage.migratePlaintextKeys(['apiBase', 'apiUrl', 'managementKey']);
|
||||
obfuscatedStorage.migratePlaintextKeys(['apiBase', 'apiUrl', 'managementKey']);
|
||||
|
||||
const wasLoggedIn = localStorage.getItem('isLoggedIn') === 'true';
|
||||
const legacyBase =
|
||||
secureStorage.getItem<string>('apiBase') ||
|
||||
secureStorage.getItem<string>('apiUrl', { encrypt: true });
|
||||
const legacyKey = secureStorage.getItem<string>('managementKey');
|
||||
obfuscatedStorage.getItem<string>('apiBase') ||
|
||||
obfuscatedStorage.getItem<string>('apiUrl', { encrypt: true });
|
||||
const legacyKey = obfuscatedStorage.getItem<string>('managementKey');
|
||||
|
||||
const { apiBase, managementKey, rememberPassword } = get();
|
||||
const resolvedBase = normalizeApiBase(apiBase || legacyBase || detectApiBaseFromLocation());
|
||||
@@ -94,6 +95,7 @@ export const useAuthStore = create<AuthStoreState>()(
|
||||
|
||||
try {
|
||||
set({ connectionStatus: 'connecting' });
|
||||
useModelsStore.getState().clearCache();
|
||||
|
||||
// 配置 API 客户端
|
||||
apiClient.setConfig({
|
||||
@@ -138,6 +140,7 @@ export const useAuthStore = create<AuthStoreState>()(
|
||||
restoreSessionPromise = null;
|
||||
useConfigStore.getState().clearCache();
|
||||
useUsageStatsStore.getState().clearUsageStats();
|
||||
useModelsStore.getState().clearCache();
|
||||
set({
|
||||
isAuthenticated: false,
|
||||
apiBase: '',
|
||||
@@ -197,14 +200,14 @@ export const useAuthStore = create<AuthStoreState>()(
|
||||
name: STORAGE_KEY_AUTH,
|
||||
storage: createJSONStorage(() => ({
|
||||
getItem: (name) => {
|
||||
const data = secureStorage.getItem<AuthStoreState>(name);
|
||||
const data = obfuscatedStorage.getItem<AuthStoreState>(name);
|
||||
return data ? JSON.stringify(data) : null;
|
||||
},
|
||||
setItem: (name, value) => {
|
||||
secureStorage.setItem(name, JSON.parse(value));
|
||||
obfuscatedStorage.setItem(name, JSON.parse(value));
|
||||
},
|
||||
removeItem: (name) => {
|
||||
secureStorage.removeItem(name);
|
||||
obfuscatedStorage.removeItem(name);
|
||||
}
|
||||
})),
|
||||
partialize: (state) => ({
|
||||
|
||||
@@ -267,7 +267,12 @@ export const useConfigStore = create<ConfigState>((set, get) => ({
|
||||
// 同时清除完整配置缓存
|
||||
newCache.delete('__full__');
|
||||
|
||||
set({ cache: newCache });
|
||||
// Section-level invalidation usually follows an optimistic write path. Invalidate any in-flight
|
||||
// full fetch so stale responses can't overwrite newer local changes.
|
||||
configRequestToken += 1;
|
||||
inFlightConfigRequest = null;
|
||||
|
||||
set({ cache: newCache, loading: false, error: null });
|
||||
return;
|
||||
} else {
|
||||
newCache.clear();
|
||||
|
||||
@@ -11,6 +11,7 @@ interface ModelsCache {
|
||||
data: ModelInfo[];
|
||||
timestamp: number;
|
||||
apiBase: string;
|
||||
apiKey: string;
|
||||
}
|
||||
|
||||
interface ModelsState {
|
||||
@@ -21,7 +22,7 @@ interface ModelsState {
|
||||
|
||||
fetchModels: (apiBase: string, apiKey?: string, forceRefresh?: boolean) => Promise<ModelInfo[]>;
|
||||
clearCache: () => void;
|
||||
isCacheValid: (apiBase: string) => boolean;
|
||||
isCacheValid: (apiBase: string, apiKey?: string) => boolean;
|
||||
}
|
||||
|
||||
export const useModelsStore = create<ModelsState>((set, get) => ({
|
||||
@@ -32,9 +33,10 @@ export const useModelsStore = create<ModelsState>((set, get) => ({
|
||||
|
||||
fetchModels: async (apiBase, apiKey, forceRefresh = false) => {
|
||||
const { cache, isCacheValid } = get();
|
||||
const apiKeyScope = apiKey?.trim() || '';
|
||||
|
||||
// 检查缓存
|
||||
if (!forceRefresh && isCacheValid(apiBase) && cache) {
|
||||
if (!forceRefresh && isCacheValid(apiBase, apiKeyScope) && cache) {
|
||||
set({ models: cache.data, error: null });
|
||||
return cache.data;
|
||||
}
|
||||
@@ -42,13 +44,13 @@ export const useModelsStore = create<ModelsState>((set, get) => ({
|
||||
set({ loading: true, error: null });
|
||||
|
||||
try {
|
||||
const list = await modelsApi.fetchModels(apiBase, apiKey);
|
||||
const list = await modelsApi.fetchModels(apiBase, apiKeyScope || undefined);
|
||||
const now = Date.now();
|
||||
|
||||
set({
|
||||
models: list,
|
||||
loading: false,
|
||||
cache: { data: list, timestamp: now, apiBase }
|
||||
cache: { data: list, timestamp: now, apiBase, apiKey: apiKeyScope }
|
||||
});
|
||||
|
||||
return list;
|
||||
@@ -68,10 +70,12 @@ export const useModelsStore = create<ModelsState>((set, get) => ({
|
||||
set({ cache: null, models: [] });
|
||||
},
|
||||
|
||||
isCacheValid: (apiBase) => {
|
||||
isCacheValid: (apiBase, apiKey) => {
|
||||
const { cache } = get();
|
||||
if (!cache) return false;
|
||||
if (cache.apiBase !== apiBase) return false;
|
||||
const apiKeyScope = apiKey?.trim() || '';
|
||||
if ((cache.apiKey || '') !== apiKeyScope) return false;
|
||||
return Date.now() - cache.timestamp < CACHE_EXPIRY_MS;
|
||||
}
|
||||
}));
|
||||
|
||||
@@ -411,6 +411,7 @@ textarea {
|
||||
border-radius: $radius-lg;
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow: $shadow-lg;
|
||||
max-width: 100%;
|
||||
max-height: 90vh;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
@@ -523,6 +524,49 @@ textarea {
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
@media (max-width: $breakpoint-mobile) {
|
||||
.modal-overlay {
|
||||
padding: $spacing-md;
|
||||
}
|
||||
|
||||
.modal {
|
||||
max-height: calc(100vh - #{$spacing-md * 2});
|
||||
border-radius: $radius-md;
|
||||
}
|
||||
|
||||
@supports (height: 100dvh) {
|
||||
.modal {
|
||||
max-height: calc(100dvh - #{$spacing-md * 2});
|
||||
}
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
padding: $spacing-md;
|
||||
padding-right: 52px;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: $spacing-md;
|
||||
max-height: min(60vh, calc(100vh - 180px));
|
||||
}
|
||||
|
||||
@supports (height: 100dvh) {
|
||||
.modal-body {
|
||||
max-height: min(60dvh, calc(100dvh - 180px));
|
||||
}
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
padding: $spacing-md;
|
||||
flex-direction: column-reverse;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.modal-footer .btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.request-log-modal {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
+14
-7
@@ -1,6 +1,8 @@
|
||||
/**
|
||||
* 加密工具函数
|
||||
* 本地存储混淆工具函数(可逆)
|
||||
* 从原项目 src/utils/secure-storage.js 迁移
|
||||
*
|
||||
* IMPORTANT: 这不是安全边界。浏览器端长期持久化的密钥仍应视为可被读取。
|
||||
*/
|
||||
|
||||
const ENC_PREFIX = 'enc::v1::';
|
||||
@@ -26,7 +28,7 @@ function getKeyBytes(): Uint8Array {
|
||||
const ua = navigator.userAgent;
|
||||
cachedKeyBytes = encodeText(`${SECRET_SALT}|${host}|${ua}`);
|
||||
} catch (error) {
|
||||
console.warn('Encryption fallback to simple key:', error);
|
||||
console.warn('Obfuscation fallback to simple key:', error);
|
||||
cachedKeyBytes = encodeText(SECRET_SALT);
|
||||
}
|
||||
|
||||
@@ -61,7 +63,7 @@ function fromBase64(base64: string): Uint8Array {
|
||||
/**
|
||||
* 加密数据
|
||||
*/
|
||||
export function encryptData(value: string): string {
|
||||
export function obfuscateData(value: string): string {
|
||||
if (!value) return value;
|
||||
|
||||
try {
|
||||
@@ -69,7 +71,7 @@ export function encryptData(value: string): string {
|
||||
const encrypted = xorBytes(encodeText(value), keyBytes);
|
||||
return `${ENC_PREFIX}${toBase64(encrypted)}`;
|
||||
} catch (error) {
|
||||
console.warn('Encryption failed, fallback to plaintext:', error);
|
||||
console.warn('Obfuscation failed, fallback to plaintext:', error);
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -77,7 +79,7 @@ export function encryptData(value: string): string {
|
||||
/**
|
||||
* 解密数据
|
||||
*/
|
||||
export function decryptData(payload: string): string {
|
||||
export function deobfuscateData(payload: string): string {
|
||||
if (!payload || !payload.startsWith(ENC_PREFIX)) {
|
||||
return payload;
|
||||
}
|
||||
@@ -88,7 +90,7 @@ export function decryptData(payload: string): string {
|
||||
const decrypted = xorBytes(encrypted, getKeyBytes());
|
||||
return decodeText(decrypted);
|
||||
} catch (error) {
|
||||
console.warn('Decryption failed, return as-is:', error);
|
||||
console.warn('Deobfuscation failed, return as-is:', error);
|
||||
return payload;
|
||||
}
|
||||
}
|
||||
@@ -96,6 +98,11 @@ export function decryptData(payload: string): string {
|
||||
/**
|
||||
* 检查是否已加密
|
||||
*/
|
||||
export function isEncrypted(value: string): boolean {
|
||||
export function isObfuscated(value: string): boolean {
|
||||
return value?.startsWith(ENC_PREFIX) || false;
|
||||
}
|
||||
|
||||
// Backward-compatible aliases (this module was historically named "encryption").
|
||||
export const encryptData = obfuscateData;
|
||||
export const decryptData = deobfuscateData;
|
||||
export const isEncrypted = isObfuscated;
|
||||
|
||||
@@ -31,6 +31,12 @@ export function buildHeaderObject(input?: HeaderEntry[] | Record<string, string
|
||||
}, {});
|
||||
}
|
||||
|
||||
export function hasHeader(headers: Record<string, unknown> | null | undefined, name: string): boolean {
|
||||
if (!headers) return false;
|
||||
const target = name.toLowerCase();
|
||||
return Object.keys(headers).some((key) => key.toLowerCase() === target);
|
||||
}
|
||||
|
||||
export function headersToEntries(headers?: Record<string, string | undefined | null>): HeaderEntry[] {
|
||||
if (!headers || typeof headers !== 'object') return [];
|
||||
return Object.entries(headers)
|
||||
|
||||
Reference in New Issue
Block a user