This commit is contained in:
2026-04-01 22:50:16 -06:00
parent bdd5a133d0
commit b80a2c11f4
5 changed files with 1222 additions and 967 deletions

View File

@@ -23,8 +23,22 @@ onMounted(() => {
</script>
<template>
<div class="app">
<div class="project-manager">
<div class="page">
<header class="header">
<div class="header-inner">
<div class="brand">
<svg class="brand-icon" width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 2L2 7l10 5 10-5-10-5z"/>
<path d="M2 17l10 5 10-5"/>
<path d="M2 12l10 5 10-5"/>
</svg>
<span class="brand-name">港湾</span>
</div>
<p class="brand-tagline">自托管 HTML 项目发布平台</p>
</div>
</header>
<main class="main">
<ProjectUpload @project-uploaded="handleProjectUpdated" />
<ProjectList
@@ -35,20 +49,83 @@ onMounted(() => {
@clear-search="clearSearch"
@project-updated="handleProjectUpdated"
/>
</div>
</main>
</div>
</template>
<style scoped>
.app {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.project-manager {
.page {
min-height: 100vh;
display: flex;
flex-direction: column;
gap: 30px;
}
.header {
background: var(--color-surface);
border-bottom: 1px solid var(--color-border);
}
.header-inner {
max-width: 1200px;
margin: 0 auto;
padding: 16px 24px;
display: flex;
align-items: center;
gap: 16px;
}
.brand {
display: flex;
align-items: center;
gap: 10px;
color: var(--color-primary);
}
.brand-icon {
flex-shrink: 0;
}
.brand-name {
font-size: 20px;
font-weight: 700;
letter-spacing: -0.02em;
color: var(--color-text);
}
.brand-tagline {
margin: 0;
font-size: 13px;
color: var(--color-text-muted);
border-left: 1px solid var(--color-border);
padding-left: 16px;
}
.main {
max-width: 1200px;
margin: 0 auto;
width: 100%;
padding: 32px 24px;
display: flex;
flex-direction: column;
gap: 24px;
}
@media (max-width: 640px) {
.header-inner {
padding: 12px 16px;
flex-wrap: wrap;
gap: 8px;
}
.brand-tagline {
border-left: none;
padding-left: 0;
width: 100%;
}
.main {
padding: 16px;
gap: 16px;
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -42,44 +42,82 @@ const handleProjectUpdated = () => {
</script>
<template>
<div class="projects-section">
<div class="projects-header">
<h2>📋 我的托管项目</h2>
<div class="search-container">
<div class="list-card">
<div class="list-header">
<div class="list-title-group">
<div class="list-icon-wrap">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="7" height="7"/>
<rect x="14" y="3" width="7" height="7"/>
<rect x="14" y="14" width="7" height="7"/>
<rect x="3" y="14" width="7" height="7"/>
</svg>
</div>
<div>
<h2 class="list-title">我的项目</h2>
<p class="list-subtitle">{{ projects.length }} 个项目</p>
</div>
</div>
<div class="search-box">
<div class="search-input-wrap">
<svg class="search-icon" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8"/>
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
<input
v-model="searchInput"
type="text"
placeholder="搜索项目名称..."
type="search"
placeholder="搜索项目..."
class="search-input"
@input="handleSearchInput"
@keyup.enter="handleSearch"
aria-label="搜索项目"
/>
</div>
<button
@click="handleSearch"
class="search-button"
class="search-btn"
:disabled="!searchInput.trim()"
>
🔍 搜索
搜索
</button>
<button
v-if="searchQuery"
@click="handleClearSearch"
class="clear-search-button"
class="clear-btn"
aria-label="清除搜索"
>
清除
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
清除
</button>
</div>
</div>
<div v-if="searchQuery" class="search-results-banner">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8"/>
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
搜索 "<strong>{{ searchQuery }}</strong>" 找到 {{ projects.length }} 个结果
</div>
<div v-if="searchQuery" class="search-results-info">
<p>搜索 "{{ searchQuery }}" 的结果 ({{ projects.length }} 个项目)</p>
</div>
<div class="list-body">
<div v-if="projects.length === 0" class="empty-state">
<p v-if="searchQuery">没有找到匹配的项目请尝试其他搜索词</p>
<p v-else>还没有托管任何项目上传一个HTML文件开始吧</p>
<div class="empty-icon">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
</svg>
</div>
<p class="empty-title">
{{ searchQuery ? "没有找到匹配的项目" : "还没有项目" }}
</p>
<p class="empty-hint">
{{ searchQuery ? "试试其他关键词" : "上传一个HTML文件开始使用吧" }}
</p>
</div>
<div v-else class="projects-grid">
@@ -92,134 +130,208 @@ const handleProjectUpdated = () => {
/>
</div>
</div>
</div>
</template>
<style scoped>
.projects-section {
background: white;
padding: 30px;
border-radius: 10px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
.list-card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
overflow: hidden;
}
.projects-header {
.list-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
gap: 20px;
justify-content: space-between;
gap: 16px;
padding: 20px 24px;
border-bottom: 1px solid var(--color-border);
flex-wrap: wrap;
}
.projects-header h2 {
.list-title-group {
display: flex;
align-items: center;
gap: 12px;
}
.list-icon-wrap {
width: 40px;
height: 40px;
border-radius: var(--radius-md);
background: var(--color-gray-100);
color: var(--color-gray-600);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.list-title {
margin: 0 0 2px;
font-size: 16px;
font-weight: 600;
color: var(--color-text);
}
.list-subtitle {
margin: 0;
color: #333;
font-size: 1.5em;
}
.search-container {
flex: 1;
max-width: 400px;
font-size: 12px;
color: var(--color-text-muted);
}
.search-box {
display: flex;
gap: 8px;
align-items: center;
gap: 8px;
}
.search-input-wrap {
position: relative;
}
.search-icon {
position: absolute;
left: 10px;
top: 50%;
transform: translateY(-50%);
color: var(--color-gray-400);
pointer-events: none;
}
.search-input {
flex: 1;
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 14px;
transition: border-color 0.3s ease;
padding: 8px 12px 8px 32px;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
font-size: 13px;
color: var(--color-text);
background: var(--color-surface);
outline: none;
width: 220px;
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
}
.search-input:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.2);
border-color: var(--color-border-focus);
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
}
.search-button,
.clear-search-button {
padding: 8px 12px;
.search-input::-webkit-search-cancel-button {
display: none;
}
.search-btn {
padding: 8px 14px;
background: var(--color-primary);
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
border-radius: var(--radius-md);
font-size: 13px;
font-weight: 500;
transition: all 0.3s ease;
cursor: pointer;
transition: background var(--transition-fast);
white-space: nowrap;
}
.search-button {
background: #667eea;
color: white;
.search-btn:hover:not(:disabled) {
background: var(--color-primary-hover);
}
.search-button:hover:not(:disabled) {
background: #5a6fd8;
transform: translateY(-1px);
}
.search-button:disabled {
background: #ccc;
.search-btn:disabled {
background: var(--color-gray-200);
color: var(--color-gray-400);
cursor: not-allowed;
transform: none;
}
.clear-search-button {
background: #dc3545;
color: white;
.clear-btn {
display: flex;
align-items: center;
gap: 5px;
padding: 8px 12px;
background: var(--color-gray-100);
color: var(--color-gray-600);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: background var(--transition-fast), color var(--transition-fast);
white-space: nowrap;
}
.clear-search-button:hover {
background: #c82333;
transform: translateY(-1px);
.clear-btn:hover {
background: var(--color-gray-200);
color: var(--color-text);
}
.search-results-info {
margin-bottom: 15px;
padding: 10px;
background: #f8f9fa;
border-radius: 6px;
border-left: 4px solid #667eea;
.search-results-banner {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 24px;
font-size: 13px;
color: var(--color-primary);
background: var(--color-primary-light);
border-bottom: 1px solid var(--color-primary-border);
}
.search-results-info p {
margin: 0;
color: #666;
font-size: 14px;
.list-body {
padding: 24px;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px 24px;
text-align: center;
padding: 60px 20px;
color: #666;
font-size: 1.1em;
}
.empty-icon {
width: 72px;
height: 72px;
border-radius: var(--radius-xl);
background: var(--color-gray-100);
color: var(--color-gray-400);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 16px;
}
.empty-title {
margin: 0 0 6px;
font-size: 15px;
font-weight: 600;
color: var(--color-text);
}
.empty-hint {
margin: 0;
font-size: 13px;
color: var(--color-text-muted);
}
.projects-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 20px;
gap: 16px;
}
/* 响应式设计:小屏幕时切换为单列 */
@media (max-width: 768px) {
@media (max-width: 900px) {
.projects-grid {
grid-template-columns: 1fr;
}
.projects-header {
flex-direction: column;
align-items: stretch;
gap: 15px;
}
.search-container {
max-width: none;
@media (max-width: 640px) {
.list-header {
padding: 16px;
flex-direction: column;
align-items: stretch;
}
.search-box {
@@ -227,7 +339,11 @@ const handleProjectUpdated = () => {
}
.search-input {
min-width: 200px;
width: 100%;
}
.list-body {
padding: 16px;
}
}
</style>

View File

@@ -12,41 +12,64 @@ const emit = defineEmits<Emits>()
const { message: uploadMessage, messageType, showMessage } = useMessage()
const projectName = ref("")
const isUploading = ref(false)
const isDragging = ref(false)
const fileInput = ref<HTMLInputElement>()
const validateFile = (file: File): boolean => {
if (!file.name.endsWith(".html")) {
showMessage("请选择HTML文件", "error")
return false
}
const maxSize = 5 * 1024 * 1024
if (file.size > maxSize) {
showMessage("文件大小不能超过5MB", "error")
return false
}
if (file.size === 0) {
showMessage("文件不能为空", "error")
return false
}
return true
}
const handleFileSelect = () => {
const file = fileInput.value?.files?.[0]
if (!file) return
// 文件类型验证
if (!file.name.endsWith(".html")) {
showMessage("请选择HTML文件", "error")
return
}
// 文件大小验证 (5MB)
const maxSize = 5 * 1024 * 1024
if (file.size > maxSize) {
showMessage("文件大小不能超过5MB", "error")
return
}
if (file.size === 0) {
showMessage("文件不能为空", "error")
return
}
if (validateFile(file)) {
uploadProject(file)
}
}
const handleDragOver = (event: DragEvent) => {
event.preventDefault()
isDragging.value = true
}
const handleDragLeave = () => {
isDragging.value = false
}
const handleDrop = (event: DragEvent) => {
event.preventDefault()
isDragging.value = false
const file = event.dataTransfer?.files?.[0]
if (!file) return
if (validateFile(file)) {
uploadProject(file)
}
}
const triggerFileSelect = () => {
fileInput.value?.click()
}
const uploadProject = async (file: File) => {
if (!projectName.value.trim()) {
showMessage("请输入项目名称", "error")
showMessage("请输入项目名称", "error")
return
}
if (projectName.value.length > 50) {
showMessage("项目名称不能超过50个字符", "error")
if (projectName.value.length > 20) {
showMessage("项目名称不能超过20个字符", "error")
return
}
@@ -58,10 +81,7 @@ const uploadProject = async (file: File) => {
formData.append("projectName", projectName.value)
const result = await api.uploadProject(formData)
showMessage(
`项目 "${result.name}" 上传成功!访问地址: ${result.url}`,
"success"
)
showMessage(`"${result.name}" 上传成功`, "success")
emit("project-uploaded")
resetForm()
} catch (error) {
@@ -69,7 +89,6 @@ const uploadProject = async (file: File) => {
error instanceof Error ? error.message : "上传失败,请检查网络连接",
"error"
)
console.error("上传错误:", error)
} finally {
isUploading.value = false
}
@@ -84,26 +103,36 @@ const resetForm = () => {
</script>
<template>
<div class="upload-section">
<h2>🚀 立即出发</h2>
<p class="description">上传HTML文件立即获得在线访问链接</p>
<div class="upload-card">
<div class="upload-header">
<div class="upload-icon-wrap">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="17 8 12 3 7 8"/>
<line x1="12" y1="3" x2="12" y2="15"/>
</svg>
</div>
<div>
<h2 class="upload-title">上传项目</h2>
<p class="upload-subtitle">上传HTML文件立即获得在线访问链接</p>
</div>
</div>
<div class="project-form">
<div class="form-group">
<label for="projectName">项目名称:</label>
<div class="upload-body">
<div class="form-field">
<label class="field-label" for="projectName">项目名称</label>
<input
v-model="projectName"
type="text"
id="projectName"
placeholder="请输入项目名称"
class="form-input"
placeholder="为你的项目起个名字"
class="field-input"
maxlength="20"
:disabled="isUploading"
/>
<small class="input-hint">最多20个字符</small>
<span class="field-hint">最多20个字符</span>
</div>
<div class="file-upload">
<input
ref="fileInput"
type="file"
@@ -112,136 +141,231 @@ const resetForm = () => {
style="display: none"
:disabled="isUploading"
/>
<button
@click="fileInput?.click()"
:disabled="isUploading"
class="upload-button"
<div
class="drop-zone"
:class="{ 'drop-zone--dragging': isDragging, 'drop-zone--disabled': isUploading }"
@dragover="handleDragOver"
@dragleave="handleDragLeave"
@drop="handleDrop"
@click="triggerFileSelect"
role="button"
tabindex="0"
@keydown.enter="triggerFileSelect"
@keydown.space.prevent="triggerFileSelect"
:aria-label="isUploading ? '上传中' : '点击选择或拖拽HTML文件到此处'"
>
{{ isUploading ? "上传中..." : "选择HTML文件" }}
</button>
<div class="drop-zone-content">
<div class="drop-icon">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" 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"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="12" y1="18" x2="12" y2="12"/>
<line x1="9" y1="15" x2="15" y2="15"/>
</svg>
</div>
<p class="drop-main-text">
{{ isUploading ? "上传中..." : "点击选择或拖拽HTML文件到此处" }}
</p>
<p class="drop-hint">支持 .html 文件最大 5MB</p>
</div>
</div>
<div
v-if="uploadMessage"
class="upload-message"
:class="{
success: messageType === 'success',
error: messageType === 'error',
}"
class="feedback-message"
:class="messageType === 'success' ? 'feedback-message--success' : 'feedback-message--error'"
role="alert"
>
<svg v-if="messageType === 'success'" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
<polyline points="22 4 12 14.01 9 11.01"/>
</svg>
<svg v-else width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/>
<line x1="12" y1="8" x2="12" y2="12"/>
<line x1="12" y1="16" x2="12.01" y2="16"/>
</svg>
{{ uploadMessage }}
</div>
</div>
</div>
</template>
<style scoped>
.upload-section {
background: white;
padding: 30px;
border-radius: 10px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
.upload-card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
overflow: hidden;
}
.upload-header {
display: flex;
align-items: center;
gap: 12px;
padding: 20px 24px;
border-bottom: 1px solid var(--color-border);
}
.upload-icon-wrap {
width: 40px;
height: 40px;
border-radius: var(--radius-md);
background: var(--color-primary-light);
color: var(--color-primary);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.upload-title {
margin: 0 0 2px;
font-size: 16px;
font-weight: 600;
color: var(--color-text);
}
.upload-subtitle {
margin: 0;
font-size: 13px;
color: var(--color-text-muted);
}
.upload-body {
padding: 24px;
display: flex;
flex-direction: column;
gap: 16px;
}
.form-field {
display: flex;
flex-direction: column;
gap: 6px;
}
.field-label {
font-size: 13px;
font-weight: 500;
color: var(--color-gray-700);
}
.field-input {
padding: 8px 12px;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
font-size: 14px;
color: var(--color-text);
background: var(--color-surface);
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
outline: none;
width: 100%;
max-width: 360px;
}
.field-input:focus {
border-color: var(--color-border-focus);
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
}
.field-input:disabled {
background: var(--color-gray-50);
color: var(--color-text-muted);
cursor: not-allowed;
}
.field-hint {
font-size: 12px;
color: var(--color-text-muted);
}
.drop-zone {
border: 2px dashed var(--color-border);
border-radius: var(--radius-lg);
padding: 32px 24px;
cursor: pointer;
transition: border-color var(--transition-base), background var(--transition-base);
text-align: center;
outline: none;
}
.upload-section h2 {
margin: 0 0 15px 0;
color: #333;
font-size: 1.5em;
.drop-zone:focus-visible {
border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
}
.description {
margin: 0 0 20px 0;
color: #666;
line-height: 1.5;
.drop-zone:hover:not(.drop-zone--disabled) {
border-color: var(--color-primary);
background: var(--color-primary-light);
}
.project-form {
display: flex;
flex-direction: column;
gap: 20px;
}
.form-group {
.drop-zone--dragging {
border-color: var(--color-primary);
background: var(--color-primary-light);
}
.drop-zone--disabled {
cursor: not-allowed;
opacity: 0.6;
}
.drop-zone-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.form-group label {
font-weight: 500;
color: #555;
.drop-icon {
color: var(--color-gray-400);
margin-bottom: 4px;
}
.form-input {
padding: 10px;
border: 1px solid #ddd;
border-radius: 5px;
.drop-zone:hover:not(.drop-zone--disabled) .drop-icon,
.drop-zone--dragging .drop-icon {
color: var(--color-primary);
}
.drop-main-text {
margin: 0;
font-size: 14px;
transition: border-color 0.3s ease;
max-width: 300px;
width: 100%;
text-align: center;
margin: 0 auto;
font-weight: 500;
color: var(--color-text);
}
.form-input:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.2);
}
.form-input:disabled {
background-color: #f5f5f5;
cursor: not-allowed;
}
.input-hint {
color: #666;
.drop-hint {
margin: 0;
font-size: 12px;
margin-top: 4px;
display: block;
color: var(--color-text-muted);
}
.upload-button {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
padding: 10px 20px;
font-size: 14px;
border-radius: 20px;
cursor: pointer;
transition: all 0.3s ease;
.feedback-message {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 12px 14px;
border-radius: var(--radius-md);
font-size: 13px;
font-weight: 500;
line-height: 1.4;
}
.upload-button:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(102, 126, 234, 0.4);
.feedback-message svg {
flex-shrink: 0;
margin-top: 1px;
}
.upload-button:disabled {
opacity: 0.6;
cursor: not-allowed;
.feedback-message--success {
background: var(--color-success-light);
color: var(--color-success);
border: 1px solid var(--color-success-border);
}
.upload-message {
margin-top: 15px;
padding: 10px;
border-radius: 5px;
text-align: center;
font-weight: 500;
}
.upload-message.success {
background: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.upload-message:not(.success) {
background: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
.feedback-message--error {
background: var(--color-danger-light);
color: var(--color-danger);
border: 1px solid var(--color-danger-border);
}
</style>

View File

@@ -1,13 +1,69 @@
/* 全局样式重置 */
* {
/* Design Tokens */
:root {
--color-primary: #2563eb;
--color-primary-hover: #1d4ed8;
--color-primary-light: #eff6ff;
--color-primary-border: #bfdbfe;
--color-danger: #dc2626;
--color-danger-hover: #b91c1c;
--color-danger-light: #fef2f2;
--color-danger-border: #fecaca;
--color-success: #16a34a;
--color-success-light: #f0fdf4;
--color-success-border: #bbf7d0;
--color-warning: #d97706;
--color-warning-light: #fffbeb;
--color-warning-border: #fde68a;
--color-gray-50: #f8fafc;
--color-gray-100: #f1f5f9;
--color-gray-200: #e2e8f0;
--color-gray-300: #cbd5e1;
--color-gray-400: #94a3b8;
--color-gray-500: #64748b;
--color-gray-600: #475569;
--color-gray-700: #334155;
--color-gray-800: #1e293b;
--color-gray-900: #0f172a;
--color-surface: #ffffff;
--color-bg: #f1f5f9;
--color-text: #1e293b;
--color-text-muted: #64748b;
--color-border: #e2e8f0;
--color-border-focus: #2563eb;
--radius-sm: 6px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-xl: 16px;
--radius-full: 9999px;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.07), 0 2px 4px -2px rgba(0, 0, 0, 0.05);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.07), 0 4px 6px -4px rgba(0, 0, 0, 0.05);
--transition-fast: 150ms ease;
--transition-base: 200ms ease;
}
/* Reset */
*, *::before, *::after {
box-sizing: border-box;
}
/* Base */
body {
margin: 0;
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
background-color: #f5f5f5;
color: #333;
font-family: -apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", system-ui, sans-serif;
font-size: 15px;
line-height: 1.5;
color: var(--color-text);
background-color: var(--color-bg);
-webkit-font-smoothing: antialiased;
}
#app {