Files
daywork/intern-log/src/views/ReportView.vue
T

232 lines
8.9 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { onMounted, ref, watch } from 'vue'
import Icon from '../components/Icon.vue'
import { buildReport, reportToMarkdown, type ReportData } from '../domain/report'
import { getWeeklyNote, saveWeeklyNote } from '../db/repo'
import { addDays, todayStr, weekStart } from '../lib/date'
import { downloadText } from '../lib/download'
import { copyText } from '../lib/clipboard'
import { polishReport } from '../domain/ai'
import { showToast } from '../lib/toast'
type RangeKey = 'week' | 'month' | 'all' | 'custom'
const RANGE_OPTIONS: { key: RangeKey; label: string }[] = [
{ key: 'week', label: '本周' },
{ key: 'month', label: '本月' },
{ key: 'all', label: '全部' },
{ key: 'custom', label: '自定义' }
]
const rangeKey = ref<RangeKey>('week')
const customStart = ref(weekStart(todayStr()))
const customEnd = ref(todayStr())
const data = ref<ReportData | null>(null)
const markdown = ref('')
const generating = ref(false)
const polishBusy = ref(false)
const prePolish = ref<string | null>(null)
const aiError = ref('')
const noteWeekStart = ref(weekStart(todayStr()))
const noteHighlights = ref('')
const noteChallenges = ref('')
const notePlan = ref('')
const noteSaved = ref(false)
async function loadNote() {
const note = await getWeeklyNote(noteWeekStart.value)
noteHighlights.value = note?.highlights || ''
noteChallenges.value = note?.challenges || ''
notePlan.value = note?.plan_next || ''
noteSaved.value = false
}
async function saveNote() {
await saveWeeklyNote(noteWeekStart.value, {
highlights: noteHighlights.value.trim() || null,
challenges: noteChallenges.value.trim() || null,
plan_next: notePlan.value.trim() || null
})
noteSaved.value = true
await generate()
}
function rangeOf(key: RangeKey): { start: string; end: string; label: string } {
const t = todayStr()
if (key === 'week') {
const ws = weekStart(t)
return { start: ws, end: addDays(ws, 6), label: '本周' }
}
if (key === 'month') {
const ym = t.slice(0, 7)
return { start: ym + '-01', end: t, label: ym + ' 本月' }
}
if (key === 'all') return { start: '2000-01-01', end: t, label: '全部实习期' }
return { start: customStart.value, end: customEnd.value, label: customStart.value + ' ~ ' + customEnd.value }
}
async function generate() {
generating.value = true
const r = rangeOf(rangeKey.value)
const d = await buildReport(r.start, r.end, r.label)
data.value = d
markdown.value = reportToMarkdown(d)
generating.value = false
}
watch(rangeKey, generate)
watch([customStart, customEnd], () => {
if (rangeKey.value === 'custom') generate()
})
onMounted(async () => {
await loadNote()
await generate()
})
async function doExport() {
const t = todayStr().replace(/-/g, '')
const p = await downloadText('实习工作总结_' + t + '.md', markdown.value)
showToast(p ? '总结已导出到:' + p : '总结已导出到系统下载目录')
}
async function doCopy() {
await copyText(markdown.value)
showToast('总结已复制到剪贴板')
}
async function doPolish() {
if (polishBusy.value) return
aiError.value = ''
const src = markdown.value.trim()
if (!src) {
aiError.value = '当前草稿为空,无法润色'
return
}
polishBusy.value = true
try {
prePolish.value = markdown.value
markdown.value = await polishReport(src)
showToast('AI 润色完成')
} catch (e) {
prePolish.value = null
aiError.value = e instanceof Error ? e.message : '润色失败'
}
polishBusy.value = false
}
function undoPolish() {
if (prePolish.value !== null) markdown.value = prePolish.value
prePolish.value = null
}
</script>
<template>
<div class="report">
<div class="report-toolbar">
<div class="seg">
<button
v-for="o in RANGE_OPTIONS" :key="o.key"
class="seg-btn" :class="{ active: rangeKey === o.key }"
@click="rangeKey = o.key"
>{{ o.label }}</button>
</div>
<template v-if="rangeKey === 'custom'">
<input type="date" class="input w-date" v-model="customStart" />
<span class="muted">至</span>
<input type="date" class="input w-date" v-model="customEnd" />
</template>
<span class="flex1"></span>
<button class="btn btn-sm" :disabled="polishBusy" @click="doPolish">
<Icon name="edit" :size="14" />AI 润色
</button>
<button v-if="prePolish !== null" class="btn btn-sm" @click="undoPolish">还原润色前</button>
<button class="btn btn-sm" :disabled="generating" @click="generate">
<Icon name="edit" :size="14" />重新生成
</button>
<button class="btn btn-sm" @click="doCopy">复制</button>
<button class="btn btn-primary btn-sm" @click="doExport">
<Icon name="export" :size="14" />导出 Markdown
</button>
</div>
<div v-if="aiError" class="ai-error">{{ aiError }}</div>
<div class="report-grid">
<div class="report-left">
<div class="card section">
<div class="section-title">本周周记({{ noteWeekStart }} 起)</div>
<label class="note-label muted">本周亮点</label>
<textarea class="textarea" v-model="noteHighlights" placeholder="本周最有价值的一件事" @blur="saveNote"></textarea>
<label class="note-label muted">踩的坑</label>
<textarea class="textarea" v-model="noteChallenges" placeholder="遇到的问题与教训" @blur="saveNote"></textarea>
<label class="note-label muted">下周计划</label>
<textarea class="textarea" v-model="notePlan" placeholder="下周准备做什么" @blur="saveNote"></textarea>
<div class="muted note-saved">{{ noteSaved ? '已保存' : '失焦自动保存' }}</div>
</div>
<div class="card section">
<div class="section-title">总结草稿(可编辑)</div>
<textarea class="textarea md-editor" v-model="markdown" spellcheck="false"></textarea>
</div>
</div>
<div class="report-right">
<div v-if="data" class="card section">
<div class="section-title">数据摘要</div>
<div class="sum-row"><span class="muted">出勤</span><span class="num">{{ data.attendDays }} 天</span></div>
<div class="sum-row"><span class="muted">工时</span><span class="num">{{ data.totalHours }} 小时</span></div>
<div class="sum-row"><span class="muted">补卡</span><span class="num">{{ data.makeupCount }} 次</span></div>
<div class="sum-row"><span class="muted">请假</span><span class="num">{{ data.leaveCount }} 天</span></div>
<div class="sum-row"><span class="muted">记录</span><span class="num">{{ data.itemCount }} 条</span></div>
<div class="section-sub">分类分布</div>
<div v-for="c in data.categoryStats" :key="c.key" class="sum-row">
<span class="muted">{{ c.label }}</span>
<span class="num">{{ c.count }} 条 · {{ c.percent }}%</span>
</div>
<div v-if="!data.categoryStats.length" class="muted sum-empty">(无)</div>
<div class="section-sub">主要成果</div>
<div v-for="(m, i) in data.milestones" :key="'m' + i" class="sum-item">
{{ m.content }}<span class="muted">({{ m.date }})</span>
</div>
<div v-if="!data.milestones.length" class="muted sum-empty">(无)</div>
<div class="section-sub">待跟进</div>
<div v-for="(f, i) in data.followups" :key="'f' + i" class="sum-item">
{{ f.content }}<span class="muted">({{ f.date }})</span>
</div>
<div v-if="!data.followups.length" class="muted sum-empty">(无)</div>
<div class="section-sub">学习与成长</div>
<div v-for="(tk, i) in data.takeaways" :key="'t' + i" class="sum-item">
{{ tk.text }}<span class="muted">({{ tk.date }})</span>
</div>
<div v-if="!data.takeaways.length" class="muted sum-empty">(无)</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.report-toolbar { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; flex-wrap: wrap; }
.ai-error { color: var(--danger); font-size: 12px; margin: 0 0 8px; }
.w-date { width: 140px; flex: none; }
.report-grid { display: grid; grid-template-columns: minmax(0, 1fr) 320px; gap: 12px; align-items: start; }
.section-title { margin-bottom: 10px; }
.section + .section { margin-top: 12px; }
.note-label { display: block; font-size: 12px; margin: 8px 0 4px; }
.note-saved { font-size: 12px; margin-top: 6px; }
.md-editor { min-height: 420px; line-height: 1.7; }
.sum-row { display: flex; justify-content: space-between; padding: 3px 0; font-size: 12px; }
.section-sub { font-weight: 600; margin: 12px 0 4px; font-size: 12px; color: var(--text-2); }
.sum-item { font-size: 12px; padding: 2px 0; word-break: break-all; }
.sum-empty { font-size: 12px; }
@media (max-width: 960px) {
.report-grid { grid-template-columns: 1fr; }
}
</style>