232 lines
8.9 KiB
Vue
232 lines
8.9 KiB
Vue
<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>
|