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
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>
|
|
|