5a26dfde2a
后端变更: - 新增 risk_config.py: 风险配置数据类,支持环境变量驱动 - 新增 risk_control.py: 风险控制控制器,管理并发和预算 - 新增 session_store.py: 匿名会话存储,基于 cookie 的 session ID - 新增 audit_store.py: API 审计日志存储,记录请求和 LLM 调用 - 新增 captcha_api.py: 验证码 API,用于验证用户操作真实性 - 新增 llm_policy.py: LLM 策略配置,管理 completion/pro/vision 模型 - main.py: 集成 middleware、risk/audit/session 模块 (+467/-7) - job_handlers.py: LLM 执行流程重构,新增 risk/audit 集成 (+207/-4) - llm.py: 异步客户端封装,新增 max_output_tokens 参数 (+78/-1) - job_system.py: stream_events 逻辑优化,支持心跳检测 (+12/-4) - pro_completions.py: SSE heartbeat 机制,防止连接超时 (+14/-4) - prompt.py: _normalize_preferences 支持 Mapping 类型 (+13/-0) - tts_asr.py: asyncio loop 初始化,router export (+10/-0) 前端变更: - src/components/CaptchaComponent.vue: 新增验证码组件 (NEW) - src/utils/cookie_policy.js: Cookie 策略工具 (NEW) - SettingsPanel.vue: 集成验证码组件,新增安全设置部分 (+59/-0) - MilkdownEditor.vue: 移除硬编码 API_KEY,新增 credentials (+32/-10) - ProBlockCrepe.vue: 样式简化,移除渐变动画 (+18/-4) - proBlockPlugin.ts: 重构 schema/serializer 引用方式,通过 Ctx 管理 (+40/-10) - api.js: 新增 credentials,重构 headers 条件逻辑 (+50/-14) - config.js: API 基址改为 https://api.imageteach.tech:8002 (+8/-4) - convert.js, docsApi.js, i18n.js: 新增 credentials 和验证码 i18n (+54/-12) - proAccept.js: 重构正则和转义处理,修复捕获组索引 (+14/-4) 配置和基础设施: - docker-compose.yml: 新增端口映射 8001:8001 (+2/-0) - docker/nginx.conf: 改为 307 redirect,优化代理配置 (+8/-6) - vite.config.js: 移除 proxy 配置,直接调用远程 API (+8/-4) - .env.example: 新增 VITE_API_BASE_URL, VITE_API_KEY (+3/-1) - backend/.env.example: 大量 RISK_*, SESSION_*, CORS_* 配置 (+54/-0) - pytest.ini: 扩展 coverage 范围到整个 backend,移除 fail_under (+3/-2) - .coveragerc: 移除 fail_under = 90 (+0/-1) - .gitignore: 新增 docker-data/ (+3/-0) - package.json: 新增 vue3-captcha 依赖 (+3/-1) - AGENTS.md, README.md: 更新 Docker 部署和前端网络约定 (+20/-5) - public/sw.js: Service Worker cache 版本从 v1 升级到 v2 (+0/-1) 测试变更: - test_main_endpoints.py: 新增 session/risk/audit reset,新增测试用例 (+63/-4) - test_main_cancel.py: 新增 reset 调用 (+6/-0) - test_pro_completions.py: 新增 preferences 序列化和测试 (+23/-0) 总计: 45 个文件变更,+1009/-280 行
732 lines
20 KiB
Vue
732 lines
20 KiB
Vue
<script setup>
|
||
import { ref, watch, computed, onMounted, onUnmounted } from 'vue'
|
||
import { useRouter, useRoute } from 'vue-router'
|
||
import { useSettingsStore } from '../stores/settings'
|
||
import { useTheme } from '../composables/useTheme'
|
||
import CaptchaComponent from './CaptchaComponent.vue'
|
||
import packageJson from '../../package.json'
|
||
|
||
const store = useSettingsStore()
|
||
const { setTheme } = useTheme()
|
||
const router = useRouter()
|
||
const route = useRoute()
|
||
|
||
const VERSION = packageJson.version || '0.0.0'
|
||
|
||
const isOpen = ref(false)
|
||
let systemThemeMediaQuery = null
|
||
|
||
const togglePanel = () => {
|
||
isOpen.value = !isOpen.value
|
||
}
|
||
|
||
const closePanel = () => {
|
||
isOpen.value = false
|
||
}
|
||
|
||
const applyThemeByPreference = () => {
|
||
if (store.theme === 'system') {
|
||
if (typeof window !== 'undefined' && typeof window.matchMedia === 'function') {
|
||
const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||
setTheme(isDark ? 'dark' : 'light')
|
||
return
|
||
}
|
||
setTheme('light')
|
||
return
|
||
}
|
||
|
||
setTheme(store.theme)
|
||
}
|
||
|
||
watch(
|
||
() => store.theme,
|
||
() => {
|
||
applyThemeByPreference()
|
||
},
|
||
{ immediate: true }
|
||
)
|
||
|
||
const handleSystemThemeChange = (event) => {
|
||
if (store.theme !== 'system') return
|
||
setTheme(event.matches ? 'dark' : 'light')
|
||
}
|
||
|
||
onMounted(() => {
|
||
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return
|
||
|
||
systemThemeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
|
||
|
||
if (typeof systemThemeMediaQuery.addEventListener === 'function') {
|
||
systemThemeMediaQuery.addEventListener('change', handleSystemThemeChange)
|
||
} else if (typeof systemThemeMediaQuery.addListener === 'function') {
|
||
systemThemeMediaQuery.addListener(handleSystemThemeChange)
|
||
}
|
||
})
|
||
|
||
onUnmounted(() => {
|
||
if (!systemThemeMediaQuery) return
|
||
|
||
if (typeof systemThemeMediaQuery.removeEventListener === 'function') {
|
||
systemThemeMediaQuery.removeEventListener('change', handleSystemThemeChange)
|
||
} else if (typeof systemThemeMediaQuery.removeListener === 'function') {
|
||
systemThemeMediaQuery.removeListener(handleSystemThemeChange)
|
||
}
|
||
})
|
||
|
||
const appearanceMode = computed({
|
||
get() {
|
||
if (store.backgroundType === 'warm') return 'warm'
|
||
if (store.backgroundType === 'reading') return 'reading'
|
||
if (store.backgroundType === 'image') return 'image'
|
||
if (store.theme === 'dark') return 'dark'
|
||
if (store.theme === 'light') return 'light'
|
||
return 'system'
|
||
},
|
||
set(mode) {
|
||
if (mode === 'dark') {
|
||
store.theme = 'dark'
|
||
store.backgroundType = 'default'
|
||
return
|
||
}
|
||
|
||
if (mode === 'light') {
|
||
store.theme = 'light'
|
||
store.backgroundType = 'default'
|
||
return
|
||
}
|
||
|
||
if (mode === 'system') {
|
||
store.theme = 'system'
|
||
store.backgroundType = 'default'
|
||
return
|
||
}
|
||
|
||
if (mode === 'warm') {
|
||
store.theme = 'light'
|
||
store.backgroundType = 'warm'
|
||
return
|
||
}
|
||
|
||
if (mode === 'reading') {
|
||
store.theme = 'light'
|
||
store.backgroundType = 'reading'
|
||
return
|
||
}
|
||
|
||
if (mode === 'image') {
|
||
store.theme = 'light'
|
||
store.backgroundType = 'image'
|
||
}
|
||
}
|
||
})
|
||
|
||
const handleImageUpload = (event) => {
|
||
const file = event.target.files[0]
|
||
if (file) {
|
||
const reader = new FileReader()
|
||
reader.onload = (e) => {
|
||
store.backgroundImage = e.target.result
|
||
store.backgroundType = 'image'
|
||
}
|
||
reader.readAsDataURL(file)
|
||
}
|
||
}
|
||
|
||
const t = (key) => store.t[key]
|
||
|
||
const switchView = (view) => {
|
||
router.push(view === 'editor' ? '/' : '/docs')
|
||
closePanel()
|
||
}
|
||
|
||
// --- Security & Captcha State ---
|
||
const showCaptcha = ref(false) // Show captcha in privacy mode
|
||
const userInput = ref('') // User's captcha input
|
||
const isValid = ref(false) // Validation success state
|
||
const attempted = ref(false) // Whether validation was attempted
|
||
const captchaComp = ref(null)
|
||
|
||
// Watch privacy mode to show/hide captcha
|
||
watch(
|
||
() => store.privacyMode,
|
||
(newVal) => {
|
||
showCaptcha.value = newVal // Show captcha when privacy mode is enabled
|
||
}
|
||
)
|
||
|
||
// Captcha validation handler
|
||
const validateCaptcha = () => {
|
||
attempted.value = true
|
||
// Call the captcha component's validate method
|
||
isValid.value = captchaComp.value?.validate(userInput.value) ?? false
|
||
|
||
// If validation failed, refresh the captcha
|
||
if (!isValid.value) {
|
||
captchaComp.value?.refresh()
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<div>
|
||
<!-- Trigger Button -->
|
||
<button
|
||
class="settings-trigger"
|
||
@click="togglePanel"
|
||
:title="t('settings')"
|
||
>
|
||
<svg viewBox="0 0 24 24" width="24" height="24" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||
<circle cx="12" cy="12" r="3"></circle>
|
||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
|
||
</svg>
|
||
</button>
|
||
|
||
<!-- Overlay (Mobile) -->
|
||
<div
|
||
class="settings-overlay"
|
||
:class="{ 'is-open': isOpen }"
|
||
@click="closePanel"
|
||
></div>
|
||
|
||
<!-- Panel -->
|
||
<div class="settings-panel" :class="{ 'is-open': isOpen }">
|
||
<div class="panel-header">
|
||
<h2>{{ t('settings') }}</h2>
|
||
<button class="close-btn" @click="closePanel" :title="t('close')">
|
||
<svg viewBox="0 0 24 24" width="24" height="24" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
|
||
<div class="panel-content">
|
||
<!-- Appearance Section -->
|
||
<section class="settings-section">
|
||
<h3>{{ t('appearance') }}</h3>
|
||
|
||
<div class="form-group">
|
||
<label>{{ t('appearance') }}</label>
|
||
<select v-model="appearanceMode" class="select-input">
|
||
<option value="dark">{{ t('dark') }}</option>
|
||
<option value="light">{{ t('light') }}</option>
|
||
<option value="system">{{ t('system') }}</option>
|
||
<option value="warm">{{ t('warm') }}</option>
|
||
<option value="reading">{{ t('reading') }}</option>
|
||
<option value="image">{{ t('image') }}</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div v-if="appearanceMode === 'image'" class="form-group">
|
||
<label>{{ t('image') }}</label>
|
||
<input type="file" accept="image/*" @change="handleImageUpload" class="file-input" />
|
||
|
||
<label class="sub-label">{{ t('opacity') }}: {{ Math.round(store.backgroundOpacity * 100) }}%</label>
|
||
<input
|
||
type="range"
|
||
min="0.05"
|
||
max="0.5"
|
||
step="0.05"
|
||
v-model.number="store.backgroundOpacity"
|
||
class="range-slider"
|
||
/>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- View Switch -->
|
||
<section class="settings-section">
|
||
<h3>{{ t('view') || '视图' }}</h3>
|
||
<div class="view-switch">
|
||
<button
|
||
class="view-btn"
|
||
:class="{ active: route.path === '/' }"
|
||
@click="switchView('editor')"
|
||
>
|
||
<svg viewBox="0 0 24 24" width="20" height="20" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||
<path d="M12 20h9"></path>
|
||
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"></path>
|
||
</svg>
|
||
<span>{{ t('editor') || '编辑器' }}</span>
|
||
</button>
|
||
<button
|
||
class="view-btn"
|
||
:class="{ active: route.path === '/docs' }"
|
||
@click="switchView('docs')"
|
||
>
|
||
<svg viewBox="0 0 24 24" width="20" height="20" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
|
||
<polyline points="14 2 14 8 20 8"></polyline>
|
||
<line x1="16" y1="13" x2="8" y2="13"></line>
|
||
<line x1="16" y1="17" x2="8" y2="17"></line>
|
||
<polyline points="10 9 9 9 8 9"></polyline>
|
||
</svg>
|
||
<span>{{ t('docs') || '文档' }}</span>
|
||
</button>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="settings-section">
|
||
<h3>{{ t('proMode') || 'PRO 模式' }}</h3>
|
||
|
||
<div class="form-group">
|
||
<label>{{ t('proThinkingLevel') || t('thinkingLevel') }}</label>
|
||
<div class="segment-control">
|
||
<button :class="{ active: store.proThinking === 'low' }" @click="store.proThinking = 'low'">{{ t('low') }}</button>
|
||
<button :class="{ active: store.proThinking === 'medium' }" @click="store.proThinking = 'medium'">{{ t('medium') }}</button>
|
||
<button :class="{ active: store.proThinking === 'high' }" @click="store.proThinking = 'high'">{{ t('high') }}</button>
|
||
</div>
|
||
<p class="help-text">{{ t('proThinkingDesc') || 'PRO 模式使用独立思考强度,普通补全设置不会影响它。' }}</p>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- Model Section -->
|
||
<section class="settings-section">
|
||
<h3>{{ t('modelIntelligence') }}</h3>
|
||
|
||
<div class="form-group">
|
||
<label>{{ t('thinkingLevel') }}</label>
|
||
<div class="segment-control">
|
||
<button :class="{ active: store.modelThinking === 'low' }" @click="store.modelThinking = 'low'">{{ t('low') }}</button>
|
||
<button :class="{ active: store.modelThinking === 'medium' }" @click="store.modelThinking = 'medium'">{{ t('medium') }}</button>
|
||
<button :class="{ active: store.modelThinking === 'high' }" @click="store.modelThinking = 'high'">{{ t('high') }}</button>
|
||
</div>
|
||
<p class="help-text">{{ store.modelThinking === 'low' ? t('lowDesc') : store.modelThinking === 'medium' ? t('mediumDesc') : t('highDesc') }}</p>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label>{{ t('debounceTime') }}: {{ store.debounceMs }}ms</label>
|
||
<input
|
||
type="range"
|
||
min="1000"
|
||
max="5000"
|
||
step="500"
|
||
v-model.number="store.debounceMs"
|
||
class="range-slider"
|
||
/>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- Privacy & Preferences -->
|
||
<section class="settings-section">
|
||
<h3>{{ t('privacyPreferences') }}</h3>
|
||
|
||
<div class="form-group toggle-group">
|
||
<label>{{ t('privacyMode') }}</label>
|
||
<button
|
||
class="toggle-switch"
|
||
:class="{ active: store.privacyMode }"
|
||
@click="store.privacyMode = !store.privacyMode"
|
||
>
|
||
<span class="toggle-thumb"></span>
|
||
</button>
|
||
</div>
|
||
<p class="help-text">{{ t('privacyDesc') }}</p>
|
||
|
||
<div class="form-group">
|
||
<label>{{ t('language') }}</label>
|
||
<select v-model="store.language" class="select-input" :disabled="store.privacyMode">
|
||
<option value="auto">{{ t('auto') }}</option>
|
||
<option value="zh">Chinese</option>
|
||
<option value="en">English</option>
|
||
<option value="ja">Japanese</option>
|
||
<option value="ko">Korean</option>
|
||
<option value="de">German</option>
|
||
<option value="fr">French</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label>{{ t('currency') }}</label>
|
||
<select v-model="store.currency" class="select-input" :disabled="store.privacyMode">
|
||
<option value="auto">{{ t('auto') }}</option>
|
||
<option value="CNY">CNY (¥)</option>
|
||
<option value="USD">USD ($)</option>
|
||
<option value="EUR">EUR (€)</option>
|
||
<option value="JPY">JPY (¥)</option>
|
||
<option value="KRW">KRW (₩)</option>
|
||
<option value="GBP">GBP (£)</option>
|
||
<option value="AUD">AUD ($)</option>
|
||
<option value="CAD">CAD ($)</option>
|
||
</select>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- Security & Captcha Section -->
|
||
<section class="settings-section" v-if="showCaptcha">
|
||
<h3>{{ t('security') || '安全设置' }}</h3>
|
||
|
||
<div class="captcha-section">
|
||
<!-- Captcha Component -->
|
||
<CaptchaComponent ref="captchaComp" />
|
||
|
||
<!-- User Input Field -->
|
||
<input
|
||
v-model="userInput"
|
||
:placeholder="t('captchaInputPlaceholder') || '请输入验证码'"
|
||
class="captcha-input"
|
||
/>
|
||
|
||
<!-- Validate Button -->
|
||
<button
|
||
@click="validateCaptcha"
|
||
class="validate-btn"
|
||
:disabled="!userInput"
|
||
>{{ t('captchaValidate') || '验证' }}</button>
|
||
|
||
<!-- Validation Result -->
|
||
<p v-if="isValid" class="success-text">{{ t('captchaSuccess') || '验证成功' }}</p>
|
||
<p v-else-if="attempted && !isValid" class="error-text">{{ t('captchaFailed') || '验证失败,请重试' }}</p>
|
||
|
||
<!-- Help Text -->
|
||
<p class="help-text">{{ t('captchaDesc') || '用于验证用户操作真实性' }}</p>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- TTS Settings -->
|
||
<section class="settings-section">
|
||
<h3>{{ t('ttsSettings') || '语音设置' }}</h3>
|
||
<div class="form-group">
|
||
<label>{{ t('voiceInstruction') || '声音描述' }}</label>
|
||
<input
|
||
type="text"
|
||
v-model="store.ttsInstruct"
|
||
class="select-input"
|
||
:placeholder="t('voiceInstructionPlaceholder') || '例如:用温柔的语气说'"
|
||
/>
|
||
<p class="help-text">{{ t('voiceInstructionDesc') || '描述你想要的声音风格,如语气、情感等' }}</p>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- About -->
|
||
<section class="settings-section">
|
||
<h3>{{ t('about') }}</h3>
|
||
<div class="about-card">
|
||
<h4>llm-in-text</h4>
|
||
<p>A smart Markdown editor with local LLM intelligence.</p>
|
||
<p class="version">v{{ VERSION }}</p>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.settings-trigger {
|
||
position: fixed;
|
||
bottom: 20px;
|
||
left: 20px;
|
||
width: 48px;
|
||
height: 48px;
|
||
border-radius: 50%;
|
||
background: var(--btn-bg);
|
||
color: var(--btn-fg);
|
||
border: 1px solid var(--panel-border);
|
||
box-shadow: var(--panel-shadow);
|
||
cursor: pointer;
|
||
z-index: 9000;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
transition: all 0.2s ease;
|
||
}
|
||
|
||
.settings-trigger:hover {
|
||
background: var(--btn-hover-bg);
|
||
color: var(--btn-hover-fg);
|
||
transform: scale(1.05);
|
||
}
|
||
|
||
.settings-panel {
|
||
position: fixed;
|
||
top: 0;
|
||
bottom: 0;
|
||
left: 0;
|
||
width: 350px;
|
||
background: var(--panel-bg);
|
||
backdrop-filter: blur(20px);
|
||
-webkit-backdrop-filter: blur(20px);
|
||
border-right: 1px solid var(--panel-border);
|
||
border-radius: 0 8px 8px 0;
|
||
box-shadow: var(--panel-shadow);
|
||
z-index: 10000;
|
||
transform: translateX(-100%);
|
||
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.settings-panel.is-open {
|
||
transform: translateX(0);
|
||
}
|
||
|
||
.settings-overlay {
|
||
position: fixed;
|
||
inset: 0;
|
||
background: rgba(0,0,0,0.2);
|
||
z-index: 9999;
|
||
opacity: 0;
|
||
pointer-events: none;
|
||
transition: opacity 0.3s;
|
||
}
|
||
|
||
.settings-overlay.is-open {
|
||
opacity: 1;
|
||
pointer-events: auto;
|
||
}
|
||
|
||
/* Mobile Fullscreen */
|
||
@media (max-width: 640px) {
|
||
.settings-panel {
|
||
top: 0;
|
||
bottom: 0;
|
||
width: 100%;
|
||
border-right: none;
|
||
border-radius: 0;
|
||
}
|
||
}
|
||
|
||
.panel-header {
|
||
padding: 16px 20px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
border-bottom: 1px solid var(--panel-border);
|
||
}
|
||
|
||
.panel-header h2 {
|
||
margin: 0;
|
||
font-size: 1.25rem;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.close-btn {
|
||
background: none;
|
||
border: none;
|
||
color: var(--muted-text);
|
||
cursor: pointer;
|
||
padding: 4px;
|
||
}
|
||
|
||
.close-btn:hover {
|
||
color: var(--app-text);
|
||
}
|
||
|
||
.panel-content {
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
padding: 20px;
|
||
min-height: 0;
|
||
}
|
||
|
||
.settings-section {
|
||
margin-bottom: 32px;
|
||
}
|
||
|
||
.settings-section h3 {
|
||
font-size: 0.875rem;
|
||
text-transform: uppercase;
|
||
color: var(--muted-text);
|
||
margin-bottom: 16px;
|
||
letter-spacing: 0.05em;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.form-group {
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
.form-group label {
|
||
display: block;
|
||
font-size: 0.9rem;
|
||
font-weight: 500;
|
||
margin-bottom: 8px;
|
||
color: var(--app-text);
|
||
}
|
||
|
||
.sub-label {
|
||
font-size: 0.8rem;
|
||
color: var(--muted-text);
|
||
margin-top: 8px;
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
.help-text {
|
||
font-size: 0.8rem;
|
||
color: var(--muted-text);
|
||
margin-top: 6px;
|
||
}
|
||
|
||
/* Segment Control */
|
||
.segment-control {
|
||
display: flex;
|
||
background: var(--ghost-code-bg);
|
||
padding: 4px;
|
||
border-radius: 8px;
|
||
}
|
||
|
||
.segment-control button {
|
||
flex: 1;
|
||
background: none;
|
||
border: none;
|
||
padding: 6px 12px;
|
||
font-size: 0.9rem;
|
||
cursor: pointer;
|
||
border-radius: 6px;
|
||
color: var(--muted-text);
|
||
transition: all 0.2s;
|
||
}
|
||
|
||
.segment-control button.active {
|
||
background: var(--app-bg);
|
||
color: var(--app-text);
|
||
box-shadow: 0 1px 2px rgba(0,0,0,0.1);
|
||
font-weight: 600;
|
||
}
|
||
|
||
/* Range Slider */
|
||
.range-slider {
|
||
width: 100%;
|
||
-webkit-appearance: none;
|
||
height: 4px;
|
||
background: var(--panel-border);
|
||
border-radius: 2px;
|
||
outline: none;
|
||
margin: 10px 0;
|
||
}
|
||
|
||
.range-slider::-webkit-slider-thumb {
|
||
-webkit-appearance: none;
|
||
width: 16px;
|
||
height: 16px;
|
||
border-radius: 50%;
|
||
background: var(--focus-ring);
|
||
cursor: pointer;
|
||
transition: transform 0.1s;
|
||
}
|
||
|
||
.range-slider::-webkit-slider-thumb:hover {
|
||
transform: scale(1.2);
|
||
}
|
||
|
||
/* Inputs */
|
||
.select-input, .file-input {
|
||
width: 100%;
|
||
padding: 8px 12px;
|
||
border-radius: 8px;
|
||
border: 1px solid var(--panel-border);
|
||
background: var(--app-bg);
|
||
color: var(--app-text);
|
||
font-size: 0.9rem;
|
||
outline: none;
|
||
}
|
||
|
||
.select-input:focus {
|
||
border-color: var(--focus-ring);
|
||
}
|
||
|
||
.color-picker-wrapper {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
}
|
||
|
||
/* Toggle Switch */
|
||
.toggle-group {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
}
|
||
|
||
.toggle-group label {
|
||
margin-bottom: 0;
|
||
}
|
||
|
||
.toggle-switch {
|
||
width: 44px;
|
||
height: 24px;
|
||
background: var(--panel-border);
|
||
border-radius: 999px;
|
||
border: none;
|
||
position: relative;
|
||
cursor: pointer;
|
||
transition: background 0.3s;
|
||
}
|
||
|
||
.toggle-switch.active {
|
||
background: var(--focus-ring);
|
||
}
|
||
|
||
.toggle-thumb {
|
||
position: absolute;
|
||
top: 2px;
|
||
left: 2px;
|
||
width: 20px;
|
||
height: 20px;
|
||
background: white;
|
||
border-radius: 50%;
|
||
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||
}
|
||
|
||
.toggle-switch.active .toggle-thumb {
|
||
transform: translateX(20px);
|
||
}
|
||
|
||
/* About Card */
|
||
.about-card {
|
||
background: var(--ghost-code-bg);
|
||
padding: 16px;
|
||
border-radius: 8px;
|
||
text-align: center;
|
||
}
|
||
|
||
.about-card h4 {
|
||
margin: 0 0 8px;
|
||
}
|
||
|
||
.about-card p {
|
||
margin: 0;
|
||
font-size: 0.9rem;
|
||
color: var(--muted-text);
|
||
}
|
||
|
||
.about-card .version {
|
||
margin-top: 8px;
|
||
font-size: 0.8rem;
|
||
opacity: 0.7;
|
||
}
|
||
|
||
.view-switch {
|
||
display: flex;
|
||
gap: 8px;
|
||
}
|
||
|
||
.view-btn {
|
||
flex: 1;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 8px;
|
||
padding: 10px 12px;
|
||
border-radius: 8px;
|
||
border: 1px solid var(--panel-border);
|
||
background: var(--ghost-code-bg);
|
||
color: var(--muted-text);
|
||
cursor: pointer;
|
||
font-size: 0.9rem;
|
||
transition: all 0.2s;
|
||
}
|
||
|
||
.view-btn:hover {
|
||
color: var(--app-text);
|
||
border-color: var(--focus-ring);
|
||
}
|
||
|
||
.view-btn.active {
|
||
background: var(--app-bg);
|
||
color: var(--app-text);
|
||
border-color: var(--focus-ring);
|
||
font-weight: 600;
|
||
}
|
||
</style>
|