Some checks failed
- Backend: FastAPI + SQLite (WAL mode), 22 tables, ~40 API endpoints - Frontend: Vue 3 + Vite + Pinia + Vue Router, 8 views, 3 stores - Database: migrate from JSON file to SQLite with proper schema - Dockerfile: multi-stage build (node + python) - Deploy: K8s manifests (namespace, deployment, service, ingress, pvc, backup) - CI/CD: Gitea Actions (test, deploy, PR preview at pr-$id.planner.oci.euphon.net) - Tests: 20 Cypress E2E test files, 196 test cases, ~85% coverage - Doc: test-coverage.md with full feature coverage report Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
149 lines
4.6 KiB
Vue
149 lines
4.6 KiB
Vue
<template>
|
||
<div class="notes-layout">
|
||
<!-- 快速输入 -->
|
||
<div class="capture-card">
|
||
<div class="capture-row">
|
||
<textarea
|
||
class="capture-input"
|
||
v-model="newText"
|
||
placeholder="想到什么,写下来…"
|
||
rows="1"
|
||
@input="autoResize"
|
||
@keydown.enter.exact.prevent="saveNote"
|
||
></textarea>
|
||
<button class="capture-btn" @click="saveNote">↑</button>
|
||
</div>
|
||
<div class="tag-btns">
|
||
<button
|
||
v-for="t in tagOptions"
|
||
:key="t.tag"
|
||
class="tag-btn"
|
||
:class="{ active: selectedTag === t.tag }"
|
||
@click="selectedTag = t.tag"
|
||
:title="t.tag"
|
||
>{{ t.icon }}</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 筛选 -->
|
||
<div class="toolbar">
|
||
<input class="search-input" v-model="searchQuery" placeholder="搜索…">
|
||
<div class="filter-row">
|
||
<button
|
||
class="filter-btn"
|
||
:class="{ active: filterTag === 'all' }"
|
||
@click="filterTag = 'all'"
|
||
>全部</button>
|
||
<button
|
||
v-for="t in tagOptions"
|
||
:key="t.tag"
|
||
class="filter-btn"
|
||
:class="{ active: filterTag === t.tag }"
|
||
@click="filterTag = t.tag"
|
||
>{{ t.icon }} {{ t.tag }}</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 列表 -->
|
||
<div v-if="filtered.length === 0" class="empty-hint">
|
||
还没有记录,在上方输入框快速记录吧
|
||
</div>
|
||
<div v-for="note in filtered" :key="note.id" class="note-card">
|
||
<div class="note-header">
|
||
<span class="note-tag" :style="{ background: tagColor(note.tag) }">{{ tagIcon(note.tag) }} {{ note.tag }}</span>
|
||
<span class="note-time">{{ formatTime(note.created_at) }}</span>
|
||
</div>
|
||
<div v-if="editingId === note.id" class="note-edit">
|
||
<textarea v-model="editText" class="edit-textarea" rows="3"></textarea>
|
||
<div class="edit-actions">
|
||
<button class="btn btn-close" @click="editingId = null">取消</button>
|
||
<button class="btn btn-accent" @click="saveEdit(note)">保存</button>
|
||
</div>
|
||
</div>
|
||
<div v-else class="note-text" @click="startEdit(note)">{{ note.text }}</div>
|
||
<div class="note-actions">
|
||
<button class="note-action-btn" @click="startEdit(note)">编辑</button>
|
||
<button class="note-action-btn danger" @click="removeNote(note.id)">删除</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, computed } from 'vue'
|
||
import { usePlannerStore } from '../stores/planner'
|
||
|
||
const store = usePlannerStore()
|
||
|
||
const tagOptions = [
|
||
{ tag: '灵感', icon: '💡' },
|
||
{ tag: '待办', icon: '✅' },
|
||
{ tag: '提醒', icon: '⏰' },
|
||
{ tag: '读书', icon: '📖' },
|
||
{ tag: '睡眠', icon: '🌙' },
|
||
{ tag: '健康', icon: '💊' },
|
||
{ tag: '健身', icon: '💪' },
|
||
{ tag: '音乐', icon: '🎵' },
|
||
]
|
||
|
||
const TAG_COLORS = {
|
||
'灵感': '#fff3cd', '待办': '#d1ecf1', '提醒': '#f8d7da', '读书': '#d4edda',
|
||
'睡眠': '#e2d9f3', '健康': '#fce4ec', '健身': '#e8f5e9', '音乐': '#fff8e1',
|
||
}
|
||
const TAG_ICONS = Object.fromEntries(tagOptions.map(t => [t.tag, t.icon]))
|
||
|
||
const newText = ref('')
|
||
const selectedTag = ref('灵感')
|
||
const searchQuery = ref('')
|
||
const filterTag = ref('all')
|
||
const editingId = ref(null)
|
||
const editText = ref('')
|
||
|
||
function tagColor(tag) { return TAG_COLORS[tag] || '#f5f5f5' }
|
||
function tagIcon(tag) { return TAG_ICONS[tag] || '📝' }
|
||
|
||
const filtered = computed(() => {
|
||
let list = store.notes
|
||
if (filterTag.value !== 'all') list = list.filter(n => n.tag === filterTag.value)
|
||
if (searchQuery.value) {
|
||
const q = searchQuery.value.toLowerCase()
|
||
list = list.filter(n => n.text.toLowerCase().includes(q))
|
||
}
|
||
return list
|
||
})
|
||
|
||
function uid() { return Date.now().toString(36) + Math.random().toString(36).slice(2, 7) }
|
||
|
||
function autoResize(e) {
|
||
e.target.style.height = 'auto'
|
||
e.target.style.height = Math.min(e.target.scrollHeight, 160) + 'px'
|
||
}
|
||
|
||
async function saveNote() {
|
||
const text = newText.value.trim()
|
||
if (!text) return
|
||
await store.addNote({ id: uid(), text, tag: selectedTag.value, created_at: new Date().toISOString() })
|
||
newText.value = ''
|
||
}
|
||
|
||
function startEdit(note) {
|
||
editingId.value = note.id
|
||
editText.value = note.text
|
||
}
|
||
|
||
async function saveEdit(note) {
|
||
await store.updateNote({ ...note, text: editText.value })
|
||
editingId.value = null
|
||
}
|
||
|
||
async function removeNote(id) {
|
||
await store.deleteNote(id)
|
||
}
|
||
|
||
function formatTime(ts) {
|
||
if (!ts) return ''
|
||
const d = new Date(ts)
|
||
return `${d.getMonth() + 1}/${d.getDate()} ${d.getHours()}:${String(d.getMinutes()).padStart(2, '0')}`
|
||
}
|
||
</script>
|