feat: 为便签编辑器新增问卷模板模式,支持全部可渲染字段的表单化编辑;调整默认视图为监控大屏

This commit is contained in:
2026-08-08 10:00:31 +08:00
parent 89b4c5f236
commit 4e69410011
3 changed files with 494 additions and 23 deletions
+481 -10
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { shallowRef, watch } from 'vue'
import { shallowRef, ref, watch, nextTick } from 'vue'
import { parseNote } from '@/utils/noteParser'
const props = defineProps<{
content: string
@@ -12,8 +13,44 @@ const emit = defineEmits<{
delete: []
}>()
type TabMode = 'markdown' | 'form'
const activeTab = shallowRef<TabMode>('form')
const text = shallowRef(props.content)
const deleteConfirm = shallowRef(false)
const textareaRef = shallowRef<HTMLTextAreaElement | null>(null)
interface FormState {
title: string
subtitle: string
status: string
progress: number | null
priority: string
deadline: string
startDate: string
owner: string
tags: string
type: string
metric: number | null
target: number | null
link: string
description: string
subTasks: SubTaskEntry[]
}
interface SubTaskEntry {
text: string
done: boolean
}
function emptyForm(): FormState {
return {
title: '', subtitle: '', status: '', progress: null, priority: '',
deadline: '', startDate: '', owner: '', tags: '', type: '',
metric: null, target: null, link: '', description: '', subTasks: [],
}
}
const form = ref<FormState>(emptyForm())
watch(() => props.content, (val) => {
text.value = val
@@ -23,11 +60,87 @@ watch(() => props.visible, (val) => {
if (val) {
text.value = props.content
deleteConfirm.value = false
parseToForm(props.content)
activeTab.value = 'form'
}
})
function parseToForm(content: string) {
const parsed = parseNote(content)
form.value = {
title: parsed.title,
subtitle: parsed.subtitle,
status: parsed.status ?? '',
progress: parsed.progress,
priority: parsed.priority ?? '',
deadline: parsed.deadline ?? '',
startDate: parsed.startDate ?? '',
owner: parsed.owner ?? '',
tags: parsed.tags.join(', '),
type: parsed.type ?? '',
metric: parsed.metric,
target: parsed.target,
link: parsed.link ?? '',
description: parsed.description,
subTasks: parsed.subTasks.map((s) => ({ text: s.text, done: s.done })),
}
}
function formToMarkdown(f: FormState): string {
const lines: string[] = []
const emitField = (key: string, value: unknown) => {
if (value === null || value === undefined || value === '') return
if (Array.isArray(value) && value.length === 0) return
if (typeof value === 'number') {
lines.push(`${key}: ${value}`)
return
}
if (Array.isArray(value)) {
lines.push(`${key}: [${value.join(', ')}]`)
return
}
lines.push(`${key}: ${String(value)}`)
}
emitField('title', f.title)
emitField('subtitle', f.subtitle)
emitField('status', f.status)
if (f.progress !== null) emitField('progress', f.progress)
emitField('priority', f.priority)
emitField('deadline', f.deadline)
emitField('startDate', f.startDate)
emitField('owner', f.owner)
emitField('tags', f.tags ? f.tags.split(/[,\s]+/).filter(Boolean) : [])
emitField('type', f.type)
if (f.metric !== null) emitField('metric', f.metric)
if (f.target !== null) emitField('target', f.target)
emitField('link', f.link)
let md = ''
if (lines.length > 0) {
md = '---\n' + lines.join('\n') + '\n---'
}
if (f.description.trim()) {
md += (md ? '\n\n' : '') + f.description.trim()
}
for (const st of f.subTasks) {
if (st.text.trim()) {
md += '\n- [' + (st.done ? 'x' : ' ') + '] ' + st.text
}
}
return md.trim()
}
function handleSave() {
emit('save', text.value)
if (activeTab.value === 'form') {
emit('save', formToMarkdown(form.value))
} else {
emit('save', text.value)
}
emit('close')
}
@@ -40,6 +153,32 @@ function handleKeydown(e: KeyboardEvent) {
handleSave()
}
}
function switchToForm() {
parseToForm(text.value)
activeTab.value = 'form'
}
function switchToMarkdown() {
text.value = formToMarkdown(form.value)
activeTab.value = 'markdown'
nextTick(() => {
textareaRef.value?.focus()
})
}
function addSubTask() {
form.value = {
...form.value,
subTasks: [...form.value.subTasks, { text: '', done: false }],
}
}
function removeSubTask(index: number) {
const updated = [...form.value.subTasks]
updated.splice(index, 1)
form.value = { ...form.value, subTasks: updated }
}
</script>
<template>
@@ -48,20 +187,168 @@ function handleKeydown(e: KeyboardEvent) {
<div class="editor-panel" @keydown="handleKeydown">
<div class="editor-header">
<span class="editor-title">编辑便签</span>
<div class="editor-hint">Ctrl+S 保存 · Esc 取消</div>
<div class="editor-tabs">
<button
class="tab-btn"
:class="{ active: activeTab === 'form' }"
@click="switchToForm()"
>
问卷模板
</button>
<button
class="tab-btn"
:class="{ active: activeTab === 'markdown' }"
@click="switchToMarkdown()"
>
自由编辑
</button>
</div>
<div v-if="activeTab === 'markdown'" class="editor-hint">Ctrl+S 保存 · Esc 取消</div>
<button class="editor-close" @click="emit('close')" title="关闭 (Esc)">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 6 6 18M6 6l12 12" />
</svg>
</button>
</div>
<textarea
ref="textRef"
v-if="activeTab === 'markdown'"
ref="textareaRef"
class="editor-textarea"
v-model="text"
placeholder="输入 Markdown 内容..."
autofocus
/>
<div v-if="activeTab === 'form'" class="form-body">
<div class="form-grid">
<label class="form-label">标题</label>
<input
class="form-input"
v-model="form.title"
placeholder="例如:登录模块开发"
/>
<label class="form-label">副标题</label>
<input
class="form-input"
v-model="form.subtitle"
placeholder="例如:用户系统重构"
/>
<label class="form-label">状态</label>
<select class="form-input" v-model="form.status">
<option value="">-- 自动推断 --</option>
<option value="待办">待办</option>
<option value="进行中">进行中</option>
<option value="已完成">已完成</option>
<option value="阻塞">阻塞</option>
<option value="延期">延期</option>
</select>
<label class="form-label">优先级</label>
<select class="form-input" v-model="form.priority">
<option value="">-- --</option>
<option value="紧急">紧急</option>
<option value="高"></option>
<option value="中"></option>
<option value="低"></option>
</select>
<label class="form-label">进度</label>
<div class="progress-row">
<input
type="range"
class="progress-slider"
min="0"
max="100"
:value="form.progress ?? 0"
@input="form = { ...form, progress: Number(($event.target as HTMLInputElement).value) }"
/>
<span class="progress-value">{{ form.progress ?? 0 }}%</span>
</div>
<label class="form-label">卡片类型</label>
<select class="form-input" v-model="form.type">
<option value="">-- 自动推断 --</option>
<option value="task">task</option>
<option value="milestone">milestone</option>
<option value="metric">metric</option>
<option value="countdown">countdown</option>
<option value="project">project</option>
</select>
<label class="form-label">截止日期</label>
<input class="form-input" type="date" v-model="form.deadline" />
<label class="form-label">开始日期</label>
<input class="form-input" type="date" v-model="form.startDate" />
<label class="form-label">负责人</label>
<input
class="form-input"
v-model="form.owner"
placeholder="例如:张三"
/>
<label class="form-label">标签</label>
<input
class="form-input"
v-model="form.tags"
placeholder="逗号分隔,如:前端, 核心"
/>
<label class="form-label">指标值</label>
<input
class="form-input"
type="number"
v-model.number="form.metric"
placeholder="例如:1500"
/>
<label class="form-label">目标值</label>
<input
class="form-input"
type="number"
v-model.number="form.target"
placeholder="例如:3000"
/>
<label class="form-label">链接</label>
<input
class="form-input form-input-full"
v-model="form.link"
placeholder="https://..."
/>
</div>
<label class="form-label">描述</label>
<textarea
class="form-input form-textarea"
v-model="form.description"
placeholder="正文描述..."
rows="4"
/>
<label class="form-label">代办步骤</label>
<div class="subtask-list">
<div v-for="(st, i) in form.subTasks" :key="i" class="subtask-row">
<input type="checkbox" v-model="st.done" class="subtask-checkbox" />
<input
class="subtask-input"
v-model="st.text"
placeholder="步骤描述..."
/>
<button class="subtask-remove" @click="removeSubTask(i)" title="删除步骤">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 6 6 18M6 6l12 12" />
</svg>
</button>
</div>
<button class="subtask-add" @click="addSubTask">+ 添加步骤</button>
</div>
</div>
<div class="editor-footer">
<button
class="delete-btn"
@@ -74,7 +361,7 @@ function handleKeydown(e: KeyboardEvent) {
{{ deleteConfirm ? '确认删除?' : '删除便签' }}
</button>
<div class="editor-markdown-hint">
支持 Markdown**粗体** #标题 - [ ] 复选框
{{ activeTab === 'form' ? '表单数据将保存为 YAML 格式' : '支持 Markdown**粗体** #标题 - [ ] 复选框' }}
</div>
<button class="editor-save-btn" @click="handleSave">
保存 (Ctrl+S)
@@ -100,9 +387,9 @@ function handleKeydown(e: KeyboardEvent) {
.editor-panel {
background: var(--surface-canvas);
border-radius: var(--radius-lg);
width: 600px;
max-width: 90vw;
max-height: 85vh;
width: 660px;
max-width: 92vw;
max-height: 88vh;
display: flex;
flex-direction: column;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
@@ -111,20 +398,47 @@ function handleKeydown(e: KeyboardEvent) {
.editor-header {
display: flex;
align-items: center;
padding: 14px 18px;
padding: 12px 18px;
border-bottom: 1px solid var(--border-canvas);
gap: 12px;
}
.editor-title {
font-size: 14px;
font-weight: 600;
color: var(--text-canvas);
white-space: nowrap;
}
.editor-tabs {
display: flex;
background: #f1f5f9;
border-radius: var(--radius-sm);
padding: 2px;
}
.tab-btn {
padding: 5px 14px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
color: #64748b;
transition: all 0.15s;
}
.tab-btn:hover {
color: #334155;
}
.tab-btn.active {
background: #fff;
color: var(--accent);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
}
.editor-hint {
font-size: 11px;
color: var(--text-canvas-secondary);
margin-left: 12px;
}
.editor-close {
@@ -158,6 +472,163 @@ function handleKeydown(e: KeyboardEvent) {
color: #cbd5e1;
}
.form-body {
flex: 1;
overflow-y: auto;
padding: 18px;
display: flex;
flex-direction: column;
gap: 14px;
}
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px 18px;
align-items: center;
}
.form-label {
font-size: 12px;
font-weight: 600;
color: #475569;
margin-top: 2px;
}
.form-input {
padding: 7px 10px;
border: 1px solid #e2e8f0;
border-radius: var(--radius-sm);
font-size: 13px;
color: var(--text-canvas);
background: #fff;
outline: none;
transition: border-color 0.15s;
width: 100%;
box-sizing: border-box;
}
.form-input:focus {
border-color: var(--accent);
box-shadow: 0 0 0 2px rgba(56, 189, 248, 0.12);
}
.form-input::placeholder {
color: #cbd5e1;
}
.form-input-full {
grid-column: 1 / -1;
}
.form-textarea {
resize: vertical;
font-family: inherit;
line-height: 1.6;
min-height: 80px;
}
select.form-input {
cursor: pointer;
appearance: auto;
}
.progress-row {
display: flex;
align-items: center;
gap: 10px;
}
.progress-slider {
flex: 1;
height: 4px;
cursor: pointer;
accent-color: var(--accent);
}
.progress-value {
font-size: 12px;
font-weight: 600;
color: var(--accent);
min-width: 32px;
text-align: right;
font-variant-numeric: tabular-nums;
}
.subtask-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.subtask-row {
display: flex;
align-items: center;
gap: 8px;
}
.subtask-checkbox {
width: 16px;
height: 16px;
accent-color: var(--accent);
cursor: pointer;
flex-shrink: 0;
}
.subtask-input {
flex: 1;
padding: 6px 10px;
border: 1px solid #e2e8f0;
border-radius: var(--radius-sm);
font-size: 13px;
color: var(--text-canvas);
outline: none;
transition: border-color 0.15s;
}
.subtask-input:focus {
border-color: var(--accent);
box-shadow: 0 0 0 2px rgba(56, 189, 248, 0.12);
}
.subtask-input::placeholder {
color: #cbd5e1;
}
.subtask-remove {
padding: 4px;
border-radius: 4px;
color: #94a3b8;
display: flex;
align-items: center;
flex-shrink: 0;
}
.subtask-remove:hover {
color: var(--status-overdue);
background: rgba(239, 68, 68, 0.08);
}
.subtask-add {
padding: 6px 12px;
border: 1px dashed #cbd5e1;
border-radius: var(--radius-sm);
font-size: 12px;
font-weight: 500;
color: #64748b;
display: inline-flex;
align-items: center;
justify-content: center;
transition: all 0.15s;
width: fit-content;
}
.subtask-add:hover {
border-color: var(--accent);
color: var(--accent);
background: rgba(56, 189, 248, 0.04);
}
.editor-footer {
display: flex;
align-items: center;
+12 -12
View File
@@ -17,18 +17,6 @@ const ui = useUiStore()
</div>
<div class="nav-center">
<div class="view-switcher">
<button
class="switcher-btn"
:class="{ active: ui.viewMode === 'canvas' }"
@click="ui.setViewMode('canvas')"
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2" />
<line x1="3" y1="9" x2="21" y2="9" />
<line x1="9" y1="3" x2="9" y2="21" />
</svg>
画布
</button>
<button
class="switcher-btn"
:class="{ active: ui.viewMode === 'dashboard' }"
@@ -42,6 +30,18 @@ const ui = useUiStore()
</svg>
大屏
</button>
<button
class="switcher-btn"
:class="{ active: ui.viewMode === 'canvas' }"
@click="ui.setViewMode('canvas')"
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2" />
<line x1="3" y1="9" x2="21" y2="9" />
<line x1="9" y1="3" x2="9" y2="21" />
</svg>
画布
</button>
</div>
</div>
<div class="nav-right" />
+1 -1
View File
@@ -3,7 +3,7 @@ import { shallowRef } from 'vue'
import type { ViewMode } from '@/types/note'
export const useUiStore = defineStore('ui', () => {
const viewMode = shallowRef<ViewMode>('canvas')
const viewMode = shallowRef<ViewMode>('dashboard')
const canvasScale = shallowRef(1)
const canvasOffsetX = shallowRef(0)
const canvasOffsetY = shallowRef(0)