You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

533 lines
17 KiB

<script setup lang="ts">
definePageMeta({ layout: 'default' });
const { user } = useAuth();
const activeTab = ref('profile');
const saving = ref(false);
// Profile
const nickname = ref('');
const bio = ref('');
// Tags management
const allTags = ref<any[]>([]);
const newTagName = ref('');
const newTagColor = ref('#c8a97e');
const tagsLoading = ref(false);
// Categories
const allCategories = ref<any[]>([]);
const newCatName = ref('');
const newCatIcon = ref('📁');
const newCatColor = ref('#c8a97e');
const catLoading = ref(false);
const editingCat = ref<any>(null);
const editCatName = ref('');
const editCatIcon = ref('');
const editCatColor = ref('#c8a97e');
const flatCategories = computed(() => {
const flat: any[] = [];
function walk(nodes: any[], depth: number) {
for (const n of nodes) {
flat.push({ ...n, _depth: depth });
if (n.children?.length) walk(n.children, depth + 1);
}
}
walk(allCategories.value, 0);
return flat;
});
async function fetchCategories() {
catLoading.value = true;
const res = await $fetch<any>('/api/categories');
if (res.code === 0) allCategories.value = res.data;
catLoading.value = false;
}
async function createCategory() {
if (!newCatName.value.trim()) return;
await $fetch('/api/categories', { method: 'POST', body: { name: newCatName.value.trim(), icon: newCatIcon.value || '📁', color: newCatColor.value } });
newCatName.value = '';
newCatIcon.value = '📁';
await fetchCategories();
}
async function startEditCat(cat: any) {
editingCat.value = cat;
editCatName.value = cat.name;
editCatIcon.value = cat.icon || '📁';
editCatColor.value = cat.color || '#c8a97e';
}
async function saveEditCat() {
if (!editingCat.value || !editCatName.value.trim()) return;
await $fetch(`/api/categories/${editingCat.value.id}`, { method: 'PATCH', body: { name: editCatName.value.trim(), icon: editCatIcon.value, color: editCatColor.value } });
editingCat.value = null;
await fetchCategories();
}
async function deleteCategory(id: number) {
if (!confirm('确定删除该分类?分类下的收藏将移至收件箱。')) return;
await $fetch(`/api/categories/${id}`, { method: 'DELETE' });
await fetchCategories();
}
// Export
const exporting = ref(false);
const importedData = ref('');
// Settings
const settings = ref<Record<string, any>>({});
const aiEndpoint = ref('');
const aiModel = ref('');
const settingsLoading = ref(true);
async function loadSettings() {
settingsLoading.value = true;
try {
const res = await $fetch<any>('/api/settings');
if (res.code === 0 && res.data) {
settings.value = res.data;
aiEndpoint.value = res.data.aiEndpoint || '';
aiModel.value = res.data.aiModel || '';
}
} finally { settingsLoading.value = false; }
}
async function saveSettings() {
saving.value = true;
await $fetch('/api/settings', {
method: 'PATCH',
body: {
aiEndpoint: aiEndpoint.value,
aiModel: aiModel.value,
},
});
saving.value = false;
}
// Tags
async function fetchTags() {
tagsLoading.value = true;
const res = await $fetch<any>('/api/tags');
if (res.code === 0) allTags.value = res.data;
tagsLoading.value = false;
}
async function createTag() {
if (!newTagName.value.trim()) return;
await $fetch('/api/tags', { method: 'POST', body: { name: newTagName.value.trim(), color: newTagColor.value } });
newTagName.value = '';
await fetchTags();
}
async function deleteTag(id: number) {
await $fetch(`/api/tags/${id}`, { method: 'DELETE' });
await fetchTags();
}
// Export
async function exportData() {
exporting.value = true;
const res = await $fetch<any>('/api/items', { params: { limit: '10000' } });
if (res.code === 0) {
const blob = new Blob([JSON.stringify(res.data.data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `万物收藏-数据导出-${new Date().toISOString().slice(0,10)}.json`;
a.click();
URL.revokeObjectURL(url);
}
exporting.value = false;
}
onMounted(() => {
nickname.value = user.value?.nickname || '';
loadSettings();
fetchTags();
fetchCategories();
});
</script>
<template>
<div class="settings-page">
<h1 class="page-title">设置</h1>
<!-- Tabs -->
<div class="settings-tabs">
<button
v-for="tab in [
{ key: 'profile', label: '个人资料' },
{ key: 'tags', label: '标签管理' },
{ key: 'categories', label: '分类管理' },
{ key: 'ai', label: 'AI 配置' },
{ key: 'export', label: '数据导入导出' },
]"
:key="tab.key"
class="tab-btn"
:class="{ active: activeTab === tab.key }"
@click="activeTab = tab.key"
>{{ tab.label }}</button>
</div>
<div class="settings-body">
<!-- Profile -->
<div v-if="activeTab === 'profile'" class="settings-section">
<div class="section-title">个人资料</div>
<div class="form-group">
<label class="form-label">用户名</label>
<input class="form-input" type="text" :value="user?.username" disabled />
</div>
<div class="form-group">
<label class="form-label">邮箱</label>
<input class="form-input" type="email" :value="user?.email || '未设置'" disabled />
</div>
<div class="form-group">
<label class="form-label">昵称</label>
<input class="form-input" type="text" v-model="nickname" placeholder="设置昵称" />
</div>
<div class="form-group">
<label class="form-label">个人简介</label>
<textarea class="form-textarea" v-model="bio" placeholder="介绍一下自己..." rows="3" />
</div>
<button class="btn-save" disabled>保 存</button>
<p class="form-hint">个人资料修改功能将在后续版本开放</p>
</div>
<!-- Tags -->
<div v-if="activeTab === 'tags'" class="settings-section">
<div class="section-title">标签管理</div>
<div class="form-group">
<label class="form-label">新建标签</label>
<div class="create-tag-row">
<input class="form-input" type="text" v-model="newTagName" placeholder="标签名称" @keydown.enter="createTag" />
<input class="color-input" type="color" v-model="newTagColor" />
<button class="btn-add-tag" @click="createTag">添加</button>
</div>
</div>
<div v-if="tagsLoading" class="loading-text">加载中...</div>
<div v-else class="tag-list">
<div v-for="tag in allTags" :key="tag.id" class="tag-row">
<span class="tag-dot" :style="{ background: tag.color || '#c8a97e' }" />
<span class="tag-name">#{{ tag.name }}</span>
<span class="tag-count">{{ tag._count || 0 }} 条收藏</span>
<button class="btn-delete-tag" @click="deleteTag(tag.id)" title="删除">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6 6 18M6 6l12 12"/></svg>
</button>
</div>
<div v-if="allTags.length === 0" class="empty-hint">还没有标签</div>
</div>
</div>
<!-- Categories -->
<div v-if="activeTab === 'categories'" class="settings-section">
<div class="section-title">分类管理</div>
<div class="form-group">
<label class="form-label">新建分类</label>
<div class="create-tag-row">
<input class="form-input" type="text" v-model="newCatName" placeholder="分类名称" @keydown.enter="createCategory" />
<input class="form-input" type="text" v-model="newCatIcon" placeholder="图标 emoji" style="flex: 0 0 60px; min-width: 60px;" maxlength="2" />
<input class="color-input" type="color" v-model="newCatColor" />
<button class="btn-add-tag" @click="createCategory">添加</button>
</div>
</div>
<div v-if="catLoading" class="loading-text">加载中...</div>
<div v-else class="cat-tree-list">
<div v-for="cat in flatCategories" :key="cat.id" class="cat-row" :style="{ paddingLeft: (cat._depth || 0) * 16 + 8 + 'px' }">
<span class="cat-icon">{{ cat.icon || '📁' }}</span>
<span class="cat-name">{{ cat.name }}</span>
<span class="cat-count">{{ cat.itemCount || 0 }} 条</span>
<button class="cat-action-btn" @click="startEditCat(cat)" title="编辑">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
</button>
<button class="cat-action-btn" @click="deleteCategory(cat.id)" title="删除">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>
</button>
</div>
<div v-if="flatCategories.length === 0" class="empty-hint">还没有分类</div>
</div>
<!-- Edit Category Modal -->
<div v-if="editingCat" class="edit-cat-card">
<div class="section-title" style="font-size:14px">编辑分类</div>
<div class="form-group">
<label class="form-label">名称</label>
<input class="form-input" type="text" v-model="editCatName" />
</div>
<div class="form-group">
<label class="form-label">图标 emoji</label>
<input class="form-input" type="text" v-model="editCatIcon" maxlength="2" />
</div>
<div class="form-group">
<label class="form-label">颜色</label>
<input class="color-input" type="color" v-model="editCatColor" />
</div>
<div style="display:flex;gap:8px">
<button class="btn-add-tag" @click="saveEditCat">保存</button>
<button class="btn-cancel-cat" @click="editingCat = null">取消</button>
</div>
</div>
</div>
<!-- AI Config -->
<div v-if="activeTab === 'ai'" class="settings-section">
<div class="section-title">AI 配置</div>
<div v-if="settingsLoading" class="loading-text">加载中...</div>
<template v-else>
<div class="form-group">
<label class="form-label">API 端点</label>
<input class="form-input" v-model="aiEndpoint" placeholder="https://api.openai.com/v1" />
</div>
<div class="form-group">
<label class="form-label">模型名称</label>
<input class="form-input" v-model="aiModel" placeholder="gpt-4o-mini" />
</div>
<button class="btn-save" :disabled="saving" @click="saveSettings">
{{ saving ? '保存中...' : '保 存' }}
</button>
<p class="form-hint">配置 AI 服务后可启用智能摘要和标签推荐功能</p>
</template>
</div>
<!-- Export -->
<div v-if="activeTab === 'export'" class="settings-section">
<div class="section-title">数据导入导出</div>
<div class="export-card">
<div class="export-icon">📤</div>
<div class="export-label">导出收藏数据</div>
<div class="export-desc">将所有收藏导出为 JSON 格式文件</div>
<button class="btn-save" :disabled="exporting" @click="exportData">
{{ exporting ? '导出中...' : '导出 JSON' }}
</button>
</div>
<div class="export-card" style="margin-top: 12px">
<div class="export-icon">📥</div>
<div class="export-label">导入数据</div>
<div class="export-desc">从 JSON 文件导入收藏数据</div>
<textarea
class="form-textarea"
v-model="importedData"
placeholder="粘贴 JSON 数据..."
rows="4"
style="margin-top: 8px"
/>
<button class="btn-save" disabled style="margin-top: 8px">导入</button>
<p class="form-hint">导入功能将在后续版本开放</p>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.settings-page {
height: 100%;
display: flex;
flex-direction: column;
padding: 24px;
max-width: 700px;
margin: 0 auto;
}
.page-title {
font-family: var(--font-display);
font-size: 24px;
font-weight: 400;
color: var(--text);
margin: 0 0 20px;
flex-shrink: 0;
}
.settings-tabs {
display: flex;
gap: 4px;
background: var(--bg3);
padding: 3px;
border-radius: 8px;
flex-shrink: 0;
margin-bottom: 24px;
}
.tab-btn {
flex: 1;
padding: 8px 12px;
border-radius: 6px;
background: transparent;
border: none;
color: var(--text2);
font-size: 13px;
cursor: pointer;
transition: all 0.15s;
}
.tab-btn.active { background: var(--bg4); color: var(--text); }
.tab-btn:hover:not(.active) { color: var(--text); }
.settings-body { flex: 1; overflow-y: auto; }
.settings-section { max-width: 480px; }
.section-title {
font-family: var(--font-display);
font-size: 16px;
color: var(--text);
margin-bottom: 16px;
}
.form-group { margin-bottom: 14px; }
.form-label {
display: block;
font-size: 12px;
color: var(--text3);
margin-bottom: 6px;
}
.form-input {
width: 100%;
background: var(--bg3);
border: 1px solid var(--border);
border-radius: 8px;
padding: 9px 12px;
color: var(--text);
font-family: var(--font-body);
font-size: 14px;
outline: none;
transition: border-color 0.15s;
box-sizing: border-box;
}
.form-input:focus { border-color: var(--border2); }
.form-input:disabled { opacity: 0.5; cursor: not-allowed; }
.form-textarea {
width: 100%;
background: var(--bg3);
border: 1px solid var(--border);
border-radius: 8px;
padding: 9px 12px;
color: var(--text);
font-family: var(--font-body);
font-size: 14px;
outline: none;
resize: vertical;
transition: border-color 0.15s;
box-sizing: border-box;
}
.form-textarea:focus { border-color: var(--border2); }
.form-hint {
font-size: 11px;
color: var(--text3);
margin-top: 8px;
}
.loading-text { color: var(--text3); font-size: 13px; padding: 20px 0; }
.empty-hint { color: var(--text3); font-size: 13px; padding: 12px 0; }
.btn-save {
background: var(--accent);
color: #1a1208;
border: none;
border-radius: 8px;
padding: 9px 24px;
font-family: var(--font-body);
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: background 0.15s, opacity 0.15s;
}
.btn-save:hover { background: #d4b88a; }
.btn-save:disabled { opacity: 0.4; cursor: not-allowed; }
.create-tag-row { display: flex; gap: 8px; }
.create-tag-row .form-input { flex: 1; }
.color-input {
width: 38px; height: 38px;
border: 1px solid var(--border);
border-radius: 8px;
cursor: pointer;
background: var(--bg3);
padding: 2px;
}
.color-input::-webkit-color-swatch-wrapper { padding: 0; }
.color-input::-webkit-color-swatch { border-radius: 5px; border: none; }
.btn-add-tag {
background: var(--accent);
color: #1a1208;
border: none;
border-radius: 8px;
padding: 8px 14px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
white-space: nowrap;
}
.btn-add-tag:hover { background: #d4b88a; }
.tag-list { margin-top: 16px; display: flex; flex-direction: column; gap: 4px; }
.tag-row {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
border-radius: 6px;
transition: background 0.15s;
}
.tag-row:hover { background: var(--bg3); }
.tag-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.tag-name { font-size: 14px; color: var(--text); }
.tag-count { font-size: 12px; color: var(--text3); flex: 1; }
.btn-delete-tag {
background: none;
border: none;
color: var(--text3);
cursor: pointer;
padding: 2px;
border-radius: 4px;
transition: color 0.15s;
}
.btn-delete-tag:hover { color: var(--error); }
.export-card {
background: var(--bg2);
border: 1px solid var(--border);
border-radius: 10px;
padding: 20px;
text-align: center;
}
.export-icon { font-size: 32px; margin-bottom: 8px; opacity: 0.7; }
.cat-tree-list { margin-top: 16px; display: flex; flex-direction: column; gap: 4px; }
.cat-row {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
border-radius: 6px;
transition: background 0.15s;
}
.cat-row:hover { background: var(--bg3); }
.cat-icon { font-size: 14px; flex-shrink: 0; }
.cat-name { font-size: 13px; color: var(--text); flex: 1; }
.cat-count { font-size: 11px; color: var(--text3); }
.cat-action-btn {
background: none;
border: none;
color: var(--text3);
cursor: pointer;
padding: 2px;
border-radius: 4px;
transition: color 0.15s;
}
.cat-action-btn:hover { color: var(--text); }
.edit-cat-card {
margin-top: 16px;
background: var(--bg2);
border: 1px solid var(--border);
border-radius: 10px;
padding: 16px;
}
.btn-cancel-cat {
background: var(--bg3);
border: 1px solid var(--border);
color: var(--text2);
padding: 8px 14px;
border-radius: 8px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
}
.btn-cancel-cat:hover { background: var(--bg4); }
.export-label { font-size: 15px; color: var(--text); font-weight: 500; margin-bottom: 4px; }
.export-desc { font-size: 12px; color: var(--text3); margin-bottom: 12px; }
</style>