feat: 为便签编辑器新增问卷模板模式,支持全部可渲染字段的表单化编辑;调整默认视图为监控大屏
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user