初始提交:实习工作日志系统(设计方案 + Vue3 应用 + Electron 打包)

This commit is contained in:
root
2026-10-15 23:55:27 +08:00
commit cfb984627d
50 changed files with 9223 additions and 0 deletions
@@ -0,0 +1,69 @@
<script setup lang="ts">
import { onUnmounted, watch } from 'vue'
const props = withDefaults(defineProps<{
open: boolean
title: string
message: string
confirmText?: string
cancelText?: string
danger?: boolean
}>(), { confirmText: '确认', cancelText: '取消', danger: false })
const emit = defineEmits<{ (e: 'cancel'): void; (e: 'confirm'): void }>()
function onKey(e: KeyboardEvent) {
if (!props.open) return
if (e.key === 'Escape') emit('cancel')
}
watch(() => props.open, v => {
if (v) window.addEventListener('keydown', onKey)
else window.removeEventListener('keydown', onKey)
})
onUnmounted(() => window.removeEventListener('keydown', onKey))
</script>
<template>
<teleport to="body">
<transition name="modal">
<div v-if="open" class="mask" @click.self="emit('cancel')">
<div class="dialog" role="dialog" :aria-label="title">
<div class="dialog-title">{{ title }}</div>
<div class="dialog-msg">{{ message }}</div>
<div class="dialog-btns">
<button class="btn btn-sm" @click="emit('cancel')">{{ cancelText }}</button>
<button class="btn btn-sm" :class="danger ? 'btn-danger-solid' : 'btn-primary'" @click="emit('confirm')">{{ confirmText }}</button>
</div>
</div>
</div>
</transition>
</teleport>
</template>
<style scoped>
.mask {
position: fixed;
inset: 0;
background: rgba(29, 33, 41, 0.4);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
}
.dialog {
width: 380px;
max-width: calc(100vw - 32px);
background: var(--bg-page);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-float);
padding: 20px;
}
.dialog-title { font-size: 14px; font-weight: 600; color: var(--text-1); }
.dialog-msg { margin-top: 8px; color: var(--text-2); font-size: 13px; line-height: 1.7; word-break: break-all; }
.dialog-btns { margin-top: 20px; display: flex; justify-content: flex-end; gap: 8px; }
.btn-danger-solid { background: var(--danger); border-color: var(--danger); color: #FFFFFF; }
.btn-danger-solid:hover { background: #D13A3F; }
.modal-enter-active, .modal-leave-active { transition: opacity 0.15s ease; }
.modal-enter-from, .modal-leave-to { opacity: 0; }
</style>
@@ -0,0 +1,155 @@
<script setup lang="ts">
import { nextTick, onUnmounted, ref, watch } from 'vue'
import Icon from './Icon.vue'
const props = defineProps<{
open: boolean
title: string
initial: { system: string; account: string; password: string; remark: string }
}>()
const emit = defineEmits<{
(e: 'cancel'): void
(e: 'save', payload: { system: string; account: string; password: string; remark: string }): void
}>()
const fSystem = ref('')
const fAccount = ref('')
const fPassword = ref('')
const fRemark = ref('')
const fError = ref('')
const systemInput = ref<HTMLInputElement | null>(null)
function onKey(e: KeyboardEvent) {
if (!props.open) return
if (e.key === 'Escape') emit('cancel')
}
watch(() => props.open, async v => {
if (v) {
fSystem.value = props.initial.system
fAccount.value = props.initial.account
fPassword.value = props.initial.password
fRemark.value = props.initial.remark
fError.value = ''
window.addEventListener('keydown', onKey)
await nextTick()
systemInput.value?.focus()
} else {
window.removeEventListener('keydown', onKey)
}
})
onUnmounted(() => window.removeEventListener('keydown', onKey))
// 生成 15 位随机密码:大小写字母与数字为主体,末尾追加一个符号
function genPassword() {
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789'
const symbols = '!@#$%^&*'
const arr = crypto.getRandomValues(new Uint8Array(14))
let s = ''
for (let i = 0; i < arr.length; i++) s += chars[arr[i] % chars.length]
s += symbols[crypto.getRandomValues(new Uint8Array(1))[0] % symbols.length]
fPassword.value = s
fError.value = ''
}
function save() {
if (!fSystem.value.trim() || !fAccount.value.trim() || !fPassword.value) {
fError.value = '系统名称、账号、密码为必填项'
return
}
emit('save', {
system: fSystem.value.trim(),
account: fAccount.value.trim(),
password: fPassword.value,
remark: fRemark.value.trim()
})
}
</script>
<template>
<teleport to="body">
<transition name="modal">
<div v-if="open" class="mask" @click.self="emit('cancel')">
<div class="dialog" role="dialog" :aria-label="title">
<div class="dialog-head">
<span class="dialog-title">{{ title }}</span>
<button class="icon-btn" title="关闭" @click="emit('cancel')">
<Icon name="close" :size="16" />
</button>
</div>
<div class="dialog-body">
<div class="form-grid">
<div class="field span2">
<label>系统名称(必填)</label>
<input ref="systemInput" class="input" v-model="fSystem" placeholder="如:企业邮箱 / OA / VPN" />
</div>
<div class="field span2">
<label>账号(必填)</label>
<input class="input" v-model="fAccount" placeholder="用户名、工号或邮箱" />
</div>
<div class="field span2">
<label>密码(必填)</label>
<div class="pwd-row">
<input class="input" v-model="fPassword" placeholder="输入或点击生成" />
<button class="btn btn-sm" @click="genPassword">生成</button>
</div>
</div>
<div class="field span2">
<label>备注</label>
<textarea class="textarea" v-model="fRemark" placeholder="用途、到期时间、负责人等(选填)"></textarea>
</div>
</div>
<div v-if="fError" class="form-error">{{ fError }}</div>
</div>
<div class="dialog-btns">
<button class="btn btn-sm" @click="emit('cancel')">取消</button>
<button class="btn btn-primary btn-sm" @click="save">保存</button>
</div>
</div>
</div>
</transition>
</teleport>
</template>
<style scoped>
.mask {
position: fixed;
inset: 0;
background: rgba(29, 33, 41, 0.4);
display: flex;
align-items: center;
justify-content: center;
z-index: 90;
}
.dialog {
width: 480px;
max-width: calc(100vw - 32px);
background: var(--bg-page);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-float);
}
.dialog-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px 12px;
border-bottom: 1px solid var(--border);
}
.dialog-title { font-size: 14px; font-weight: 600; color: var(--text-1); }
.dialog-body { padding: 16px 20px; }
.dialog-btns {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 12px 20px 16px;
border-top: 1px solid var(--border);
}
.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px 16px; }
.field.span2 { grid-column: span 2; }
.field label { display: block; font-size: 12px; color: var(--text-2); margin-bottom: 4px; }
.pwd-row { display: flex; gap: 8px; }
.pwd-row .input { flex: 1; }
.form-error { color: var(--danger); font-size: 12px; margin-top: 8px; }
.modal-enter-active, .modal-leave-active { transition: opacity 0.15s ease; }
.modal-enter-from, .modal-leave-to { opacity: 0; }
</style>
+11
View File
@@ -0,0 +1,11 @@
<script setup lang="ts">
import { computed } from 'vue'
import { ICONS } from './icons'
const props = withDefaults(defineProps<{ name: string; size?: number }>(), { size: 16 })
const html = computed(() => ICONS[props.name] || '')
</script>
<template>
<span class="icon" :style="{ width: size + 'px', height: size + 'px' }" v-html="html"></span>
</template>
+25
View File
@@ -0,0 +1,25 @@
// 自绘 SVG 图标:24 画布 / 1.5 描边 / 圆角端点 / currentColor(设计方案 第 8 节)
function svg(body: string): string {
return '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">' + body + '</svg>'
}
export const ICONS: Record<string, string> = {
calendar: svg('<rect x="4" y="5.5" width="16" height="14" rx="2"/><path d="M8.5 3.5v3.5M15.5 3.5v3.5M4 10.5h16"/>'),
clock: svg('<circle cx="12" cy="12" r="8.25"/><path d="M12 7.75V12l2.75 1.75"/>'),
'check-circle': svg('<circle cx="12" cy="12" r="8.25"/><path d="M8.5 12.4l2.4 2.4 4.6-5.2"/>'),
edit: svg('<path d="M4.5 19.5v-3.2L16 4.8a1.9 1.9 0 0 1 2.7 2.7L7.2 19l-2.7.5z"/>'),
tag: svg('<path d="M4.5 5.5a1 1 0 0 1 1-1h5.6l8.4 8.4-6.6 6.6-8.4-8.4V5.5z"/><circle cx="8.8" cy="8.8" r="1.1"/>'),
star: svg('<path d="M12 4l2.5 5.1 5.6.8-4 3.9.9 5.6-5-2.7-5 2.7.9-5.6-4-3.9 5.6-.8L12 4z"/>'),
close: svg('<path d="M6.5 6.5l11 11M17.5 6.5l-11 11"/>'),
flag: svg('<path d="M6.5 21V4"/><path d="M6.5 4.5h10.8l-2.6 3.7 2.6 3.7H6.5"/>'),
chart: svg('<path d="M4.5 19.5h15"/><path d="M7.5 19.5V12M12 19.5V6.5M16.5 19.5V9.5"/>'),
export: svg('<path d="M12 4.5V14M8.25 10.25L12 14l3.75-3.75"/><path d="M5 19.5h14"/>'),
'chevron-left': svg('<path d="M14.5 6l-6 6 6 6"/>'),
'chevron-right': svg('<path d="M9.5 6l6 6-6 6"/>'),
'chevron-down': svg('<path d="M6 9.5l6 6 6-6"/>'),
plus: svg('<path d="M12 5.5v13M5.5 12h13"/>'),
search: svg('<circle cx="11" cy="11" r="6.5"/><path d="M15.8 15.8L20 20"/>'),
settings: svg('<circle cx="12" cy="12" r="3"/><path d="M12 4.5v2.2M12 17.3v2.2M4.5 12h2.2M17.3 12h2.2M6.8 6.8l1.6 1.6M15.6 15.6l1.6 1.6M17.2 6.8l-1.6 1.6M8.4 15.6l-1.6 1.6"/>'),
memo: svg('<path d="M6.5 3.5h7.5L18.5 8v11a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1v-15a1 1 0 0 1 1-1z"/><path d="M14 3.5V8h4.5M9.5 12h5M9.5 15.5h5"/>'),
lock: svg('<rect x="5.5" y="10.5" width="13" height="9.5" rx="1.5"/><path d="M8.5 10.5V8a3.5 3.5 0 0 1 7 0v2.5"/>')
}
+542
View File
@@ -0,0 +1,542 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import Icon from '../Icon.vue'
import ConfirmDialog from '../ConfirmDialog.vue'
import EditLogDialog from './EditLogDialog.vue'
import {
addLogItem, clockIn, clockOut, deleteLogItem, getAttendance, getLastLocation, getLastPick,
getLocationPresets, getWorkRule, listLogItemsByDate, listProjects, saveMakeup,
setAttendanceLocation, setAttendanceTime, setLeave, updateLogItem
} from '../../db/repo'
import { CATEGORIES, categoryLabel, type Attendance, type LogItem, type Project } from '../../db/schema'
import { formatDateCN, hhmm, todayStr } from '../../lib/date'
import { computeDay, type DayAttendanceInfo } from '../../domain/attendance'
import type { WorkRule } from '../../db/repo'
import { showToast } from '../../lib/toast'
import { attendanceChanged, notifyAttendanceChanged } from '../../lib/bus'
const props = defineProps<{ date: string }>()
const loading = ref(true)
const rule = ref<WorkRule | null>(null)
const att = ref<Attendance | undefined>(undefined)
const items = ref<LogItem[]>([])
const projectsAll = ref<Project[]>([])
const locationVal = ref('')
const locationPresets = ref<string[]>([])
const dlId = computed(() => 'loc-list-' + props.date)
const isToday = computed(() => props.date === todayStr())
async function load() {
loading.value = true
rule.value = await getWorkRule()
att.value = await getAttendance(props.date)
items.value = await listLogItemsByDate(props.date)
projectsAll.value = await listProjects(true)
locationPresets.value = await getLocationPresets()
locationVal.value = (att.value && att.value.location) || (await getLastLocation())
loading.value = false
}
onMounted(load)
watch(() => props.date, load)
// 顶栏或其他视图发生考勤变更时实时刷新本面板
watch(attendanceChanged, () => { load() })
const projects = computed(() => projectsAll.value.filter(p => p.archived !== 1))
function projectName(id: number | null): string {
if (!id) return ''
const found = projectsAll.value.find(p => p.id === id)
return found ? found.name : '已删除项目'
}
const dayInfo = computed<DayAttendanceInfo | null>(() => {
if (!rule.value) return null
return computeDay(att.value, props.date, rule.value, isToday.value)
})
const totalMinutes = computed(() => items.value.reduce((s, i) => s + (i.minutes || 0), 0))
// ---- 打卡 ----
const busy = ref(false)
async function doIn() {
if (busy.value) return
busy.value = true
await clockIn(props.date, locationVal.value)
await load()
notifyAttendanceChanged()
showToast('上班打卡成功')
busy.value = false
}
async function doOut() {
if (busy.value) return
busy.value = true
await clockOut(props.date)
await load()
notifyAttendanceChanged()
showToast('下班打卡成功')
busy.value = false
}
// ---- 上班地点 ----
async function onLocationChange() {
await setAttendanceLocation(props.date, locationVal.value)
notifyAttendanceChanged()
showToast('地点已更新')
}
// ---- 休假标记 ----
const leaveConfirmOpen = ref(false)
const isLeave = computed(() => att.value?.kind === 'leave')
function toggleLeave() {
if (att.value && att.value.clock_in_at) {
leaveConfirmOpen.value = true
return
}
applyLeave(true)
}
async function applyLeave(leave: boolean) {
leaveConfirmOpen.value = false
await setLeave(props.date, leave)
await load()
notifyAttendanceChanged()
showToast(leave ? '已标记为休假' : '已取消休假')
}
// ---- 打卡时间行内编辑 ----
const editingField = ref<'in' | 'out' | null>(null)
const editTimeVal = ref('')
const timeInput = ref<HTMLInputElement | null>(null)
watch(editingField, async v => {
if (v) {
await nextTick()
timeInput.value?.focus()
}
})
function startTimeEdit(field: 'in' | 'out') {
const cur = field === 'in' ? att.value?.clock_in_at : att.value?.clock_out_at
editingField.value = field
editTimeVal.value = cur ? hhmm(cur) : (field === 'in' ? '09:00' : '18:00')
}
function cancelTimeEdit() {
editingField.value = null
}
async function saveTimeEdit() {
const f = editingField.value
if (!f) return
editingField.value = null
if (!editTimeVal.value) return
await setAttendanceTime(props.date, f === 'in' ? 'clock_in_at' : 'clock_out_at', editTimeVal.value)
await load()
notifyAttendanceChanged()
showToast('打卡时间已修改')
}
// ---- 补卡 ----
const showMakeup = ref(false)
const mkIn = ref('09:00')
const mkOut = ref('18:00')
const mkNote = ref('')
const mkLocation = ref('')
const mkError = ref('')
function openMakeup() {
mkLocation.value = locationVal.value
mkError.value = ''
showMakeup.value = !showMakeup.value
}
async function saveMakeupForm() {
if (!mkIn.value || !mkOut.value) {
mkError.value = '请填写上班与下班时间'
return
}
await saveMakeup(props.date, props.date + 'T' + mkIn.value + ':00', props.date + 'T' + mkOut.value + ':00', mkNote.value.trim(), mkLocation.value)
showMakeup.value = false
mkNote.value = ''
mkError.value = ''
await load()
notifyAttendanceChanged()
showToast('补卡已保存')
}
// ---- 新增工作记录 ----
const content = ref('')
const contentError = ref('')
watch(content, () => {
if (contentError.value) contentError.value = ''
})
const projectId = ref<number | null>(null)
const category = ref('dev')
const minutesVal = ref<number | null>(null)
const takeawayVal = ref('')
const remarkVal = ref('')
const submitting = ref(false)
const formDirty = computed(() => !!(content.value.trim() || minutesVal.value || takeawayVal.value.trim() || remarkVal.value.trim()))
onMounted(async () => {
const lp = await getLastPick()
projectId.value = lp.project_id
category.value = lp.category
})
function resetForm() {
content.value = ''
minutesVal.value = null
takeawayVal.value = ''
remarkVal.value = ''
contentError.value = ''
}
async function submit() {
const text = content.value.trim()
if (!text) {
contentError.value = '请填写工作内容'
return
}
if (submitting.value) return
submitting.value = true
await addLogItem(props.date, {
content: text,
project_id: projectId.value,
category: category.value,
minutes: minutesVal.value && minutesVal.value > 0 ? minutesVal.value : null,
takeaway: takeawayVal.value.trim() || null,
remark: remarkVal.value.trim() || null
})
resetForm()
await load()
showToast('记录已添加')
submitting.value = false
}
// ---- 条目操作 ----
const editTarget = ref<LogItem | null>(null)
function openEdit(it: LogItem) {
editTarget.value = it
}
async function onEditSave(payload: {
id: number
content: string
project_id: number | null
category: string
minutes: number | null
takeaway: string | null
remark: string | null
is_milestone: number
needs_followup: number
}) {
const id = payload.id
const patch = {
content: payload.content,
project_id: payload.project_id,
category: payload.category,
minutes: payload.minutes,
takeaway: payload.takeaway,
remark: payload.remark,
is_milestone: payload.is_milestone,
needs_followup: payload.needs_followup
}
await updateLogItem(id, patch)
editTarget.value = null
await load()
showToast('修改已保存')
}
async function toggleMilestone(it: LogItem) {
await updateLogItem(it.id as number, { is_milestone: it.is_milestone ? 0 : 1 })
await load()
}
async function toggleFollowup(it: LogItem) {
await updateLogItem(it.id as number, { needs_followup: it.needs_followup ? 0 : 1 })
await load()
}
const deleteTarget = ref<LogItem | null>(null)
async function confirmDelete() {
if (deleteTarget.value && deleteTarget.value.id) {
await deleteLogItem(deleteTarget.value.id)
}
deleteTarget.value = null
await load()
showToast('记录已删除')
}
</script>
<template>
<div class="day-panel">
<div class="dp-head">
<div class="dp-date">{{ formatDateCN(date) }}</div>
</div>
<div class="card sec">
<div class="sec-title">出勤信息</div>
<div class="att-grid">
<div class="att-cell">
<label>上班时间</label>
<template v-if="editingField === 'in'">
<div class="edit-row">
<input
ref="timeInput" type="time" class="input time-input"
v-model="editTimeVal" @keydown.enter="saveTimeEdit" @keydown.esc="cancelTimeEdit"
/>
<button class="btn btn-primary btn-sm" @click="saveTimeEdit">确认</button>
<button class="btn btn-sm" @click="cancelTimeEdit">取消</button>
</div>
</template>
<div v-else class="att-val">
<span class="num" :class="{ 'val-empty': !(att && att.clock_in_at) }">{{ att && att.clock_in_at ? hhmm(att.clock_in_at) : '未打卡' }}</span>
<button v-if="att && att.id" class="icon-btn" title="修改上班时间" @click="startTimeEdit('in')">
<Icon name="edit" :size="13" />
</button>
</div>
</div>
<div class="att-cell">
<label>下班时间</label>
<template v-if="editingField === 'out'">
<div class="edit-row">
<input
ref="timeInput" type="time" class="input time-input"
v-model="editTimeVal" @keydown.enter="saveTimeEdit" @keydown.esc="cancelTimeEdit"
/>
<button class="btn btn-primary btn-sm" @click="saveTimeEdit">确认</button>
<button class="btn btn-sm" @click="cancelTimeEdit">取消</button>
</div>
</template>
<div v-else class="att-val">
<span class="num" :class="{ 'val-empty': !(att && att.clock_out_at) }">{{ att && att.clock_out_at ? hhmm(att.clock_out_at) : '未打卡' }}</span>
<button v-if="att && att.id" class="icon-btn" title="修改下班时间" @click="startTimeEdit('out')">
<Icon name="edit" :size="13" />
</button>
</div>
</div>
<div class="att-cell">
<label>上班地点</label>
<input
class="input" v-model="locationVal"
:list="dlId" placeholder="可直接输入" title="上班地点,可自由输入" @change="onLocationChange"
/>
<datalist :id="dlId">
<option v-for="opt in locationPresets" :key="opt" :value="opt"></option>
</datalist>
</div>
<div class="att-cell">
<label>考勤状态</label>
<div class="att-val status-val">
<template v-if="dayInfo">
<span class="dot" :class="dayInfo.status"></span>
<span>{{ dayInfo.label }}</span>
<span v-if="att && att.kind === 'makeup'" class="tag">补卡</span>
</template>
<span v-else class="muted">--</span>
</div>
</div>
</div>
<div v-if="att && att.note" class="att-note muted">补卡说明:{{ att.note }}</div>
<div class="att-actions">
<button
v-if="isToday && (!att || !att.clock_in_at)"
class="btn btn-primary btn-sm" :disabled="busy" @click="doIn"
>上班打卡</button>
<button
v-else-if="isToday && att && att.clock_in_at && !att.clock_out_at"
class="btn btn-primary btn-sm" :disabled="busy" @click="doOut"
>下班打卡</button>
<button
v-if="!isToday && (!att || !att.clock_in_at)"
class="btn btn-sm" @click="openMakeup"
>补卡</button>
<button
v-if="!isLeave" class="btn btn-sm" :disabled="busy" @click="toggleLeave"
>标记休假</button>
<button
v-else class="btn btn-sm" :disabled="busy" @click="applyLeave(false)"
>取消休假</button>
</div>
</div>
<div v-if="showMakeup" class="card sec makeup">
<div class="sec-title">补卡登记</div>
<div class="form-grid">
<div class="field">
<label>上班时间</label>
<input type="time" class="input" v-model="mkIn" />
</div>
<div class="field">
<label>下班时间</label>
<input type="time" class="input" v-model="mkOut" />
</div>
<div class="field span2">
<label>上班地点</label>
<input class="input" :list="dlId" v-model="mkLocation" placeholder="可直接输入" />
</div>
<div class="field span2">
<label>补卡原因</label>
<input class="input" v-model="mkNote" placeholder="如:外出拜访客户忘记打卡" />
</div>
</div>
<div v-if="mkError" class="form-error">{{ mkError }}</div>
<div class="form-footer">
<button class="btn btn-sm" @click="showMakeup = false">取消</button>
<button class="btn btn-primary btn-sm" @click="saveMakeupForm">保存补卡</button>
</div>
</div>
<div class="card sec">
<div class="sec-head">
<span class="sec-title">新增工作记录</span>
<span class="muted form-hint">内容必填,其余选填</span>
</div>
<div class="form-grid">
<div class="field span2">
<label>工作内容(必填)</label>
<input
class="input" :class="{ invalid: contentError }"
v-model="content" placeholder="做了什么,一句话一条" @keydown.enter="submit"
/>
<div v-if="contentError" class="form-error">{{ contentError }}</div>
</div>
<div class="field">
<label>所属项目</label>
<select class="select" v-model="projectId">
<option :value="null">无项目</option>
<option v-for="p in projects" :key="p.id" :value="p.id">{{ p.name }}</option>
</select>
</div>
<div class="field">
<label>工作分类</label>
<select class="select" v-model="category">
<option v-for="c in CATEGORIES" :key="c.key" :value="c.key">{{ c.label }}</option>
</select>
</div>
<div class="field">
<label>耗时(分钟)</label>
<input class="input" type="number" min="0" v-model.number="minutesVal" placeholder="选填" />
</div>
<div class="field">
<label>主要收获</label>
<input class="input" v-model="takeawayVal" placeholder="一句话:学到了什么" />
</div>
<div class="field span2">
<label>备注</label>
<textarea class="textarea" v-model="remarkVal" placeholder="背景、链接、未决事项(选填)"></textarea>
</div>
</div>
<div class="form-footer">
<button class="btn btn-sm" :disabled="!formDirty" @click="resetForm">重置</button>
<button class="btn btn-primary btn-sm" :disabled="submitting" @click="submit">添加记录</button>
</div>
</div>
<div class="list-sec">
<div class="sec-title">工作记录({{ items.length }})</div>
<div v-if="!items.length && !loading" class="empty muted">暂无记录</div>
<div v-for="it in items" :key="it.id" class="item">
<div class="item-main">
<span class="tag">{{ categoryLabel(it.category) }}</span>
<span class="item-content" title="点击编辑" @click="openEdit(it)">{{ it.content }}</span>
<span v-if="it.project_id" class="muted item-meta">{{ projectName(it.project_id) }}</span>
<span v-if="it.minutes" class="muted num item-meta">{{ it.minutes }} 分钟</span>
<span class="flex1"></span>
<button class="icon-btn" title="编辑完整信息" @click="openEdit(it)">
<Icon name="edit" :size="14" />
</button>
<button
class="icon-btn" :class="{ active: it.is_milestone === 1 }"
title="成果标记" @click="toggleMilestone(it)"
>
<Icon name="star" :size="14" />
</button>
<button
class="icon-btn" :class="{ 'active-warn': it.needs_followup === 1 }"
title="待跟进" @click="toggleFollowup(it)"
>
<Icon name="flag" :size="14" />
</button>
<button class="icon-btn" title="删除" @click="deleteTarget = it">
<Icon name="close" :size="14" />
</button>
</div>
<div v-if="it.takeaway || it.remark" class="item-sub">
<div v-if="it.takeaway" class="muted2">收获:{{ it.takeaway }}</div>
<div v-if="it.remark" class="muted">备注:{{ it.remark }}</div>
</div>
</div>
<div v-if="items.length" class="dp-foot muted num">
共 {{ items.length }} 条事项
<template v-if="totalMinutes"> · {{ (totalMinutes / 60).toFixed(1) }} 小时</template>
</div>
</div>
<ConfirmDialog
:open="!!deleteTarget"
title="删除确认"
:message="deleteTarget ? '将删除工作记录「' + deleteTarget.content + '」。删除后不可恢复。' : ''"
confirm-text="删除" danger
@cancel="deleteTarget = null"
@confirm="confirmDelete"
/>
<ConfirmDialog
:open="leaveConfirmOpen"
title="标记休假确认"
message="该日已有打卡记录,标记休假将清空上下班时间。是否继续?"
confirm-text="标记休假" danger
@cancel="leaveConfirmOpen = false"
@confirm="applyLeave(true)"
/>
<EditLogDialog
:open="!!editTarget"
:item="editTarget"
:projects="projectsAll"
:date="date"
@cancel="editTarget = null"
@save="onEditSave"
/>
</div>
</template>
<style scoped>
.dp-head { margin-bottom: 12px; }
.dp-date { font-size: 14px; font-weight: 600; }
.sec { padding: 14px 16px; margin-bottom: 12px; }
.sec-title { font-size: 13px; font-weight: 600; margin-bottom: 10px; color: var(--text-1); }
.sec-head { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; margin-bottom: 10px; }
.sec-head .sec-title { margin-bottom: 0; }
.form-hint { font-size: 12px; }
.att-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px 16px; }
.att-cell label, .field label { display: block; font-size: 12px; color: var(--text-2); margin-bottom: 4px; }
.att-val { display: flex; align-items: center; gap: 6px; min-height: 32px; font-size: 13px; }
.status-val { min-height: 32px; }
.val-empty { color: var(--text-3); }
.edit-row { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
.time-input { width: 104px; }
.att-note { margin-top: 10px; font-size: 12px; }
.att-actions { display: flex; gap: 8px; margin-top: 12px; }
.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px 16px; }
.field.span2 { grid-column: span 2; }
.form-footer {
display: flex; justify-content: flex-end; gap: 8px;
margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border);
}
.form-error { color: var(--danger); font-size: 12px; margin-top: 4px; }
.input.invalid { border-color: var(--danger); }
.input.invalid:focus { border-color: var(--danger); }
.list-sec { margin-bottom: 4px; }
.item { border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 8px 10px; margin-bottom: 6px; background: var(--bg-page); }
.item-main { display: flex; align-items: center; gap: 8px; min-height: 26px; flex-wrap: wrap; }
.item-content { cursor: text; word-break: break-all; }
.item-meta { font-size: 12px; }
.item-sub {
margin-top: 6px; padding-top: 6px; border-top: 1px dashed var(--border);
font-size: 12px; display: flex; flex-direction: column; gap: 2px;
}
.empty { text-align: center; padding: 24px 0; }
.dp-foot { margin-top: 10px; font-size: 12px; text-align: right; }
@media (max-width: 560px) {
.att-grid, .form-grid { grid-template-columns: 1fr; }
.field.span2 { grid-column: auto; }
}
</style>
@@ -0,0 +1,243 @@
<script setup lang="ts">
import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
import Icon from '../Icon.vue'
import { CATEGORIES, type LogItem, type Project } from '../../db/schema'
const props = defineProps<{
open: boolean
item: LogItem | null
projects: Project[]
date?: string
}>()
const emit = defineEmits<{
(e: 'cancel'): void
(e: 'save', payload: {
id: number
content: string
project_id: number | null
category: string
minutes: number | null
takeaway: string | null
remark: string | null
is_milestone: number
needs_followup: number
}): void
}>()
const formContent = ref('')
const formProjectId = ref<number | null>(null)
const formCategory = ref('dev')
const formMinutes = ref<number | null>(null)
const formTakeaway = ref('')
const formRemark = ref('')
const formMilestone = ref(0)
const formFollowup = ref(0)
const contentError = ref('')
const contentInput = ref<HTMLInputElement | null>(null)
// 归档项目回显:当前条目引用了已归档项目时,下拉中追加该项避免丢失
const projectOptions = computed(() => {
const active = props.projects.filter(p => p.archived !== 1)
const cur = props.item?.project_id
if (cur && !active.some(p => p.id === cur)) {
const arch = props.projects.find(p => p.id === cur)
const list = active.slice()
if (arch) list.unshift({ ...arch, name: arch.name + '(已归档)' })
return list
}
return active
})
function onKey(e: KeyboardEvent) {
if (!props.open) return
if (e.key === 'Escape') emit('cancel')
}
watch(() => props.open, async v => {
if (v) {
const it = props.item
formContent.value = it?.content ?? ''
formProjectId.value = it?.project_id ?? null
formCategory.value = it?.category ?? 'dev'
formMinutes.value = it?.minutes ?? null
formTakeaway.value = it?.takeaway ?? ''
formRemark.value = it?.remark ?? ''
formMilestone.value = it?.is_milestone ?? 0
formFollowup.value = it?.needs_followup ?? 0
contentError.value = ''
window.addEventListener('keydown', onKey)
await nextTick()
contentInput.value?.focus()
} else {
window.removeEventListener('keydown', onKey)
}
})
onUnmounted(() => window.removeEventListener('keydown', onKey))
watch(formContent, () => {
if (contentError.value) contentError.value = ''
})
function save() {
const text = formContent.value.trim()
if (!text) {
contentError.value = '请填写工作内容'
return
}
if (!props.item || !props.item.id) {
emit('cancel')
return
}
emit('save', {
id: props.item.id,
content: text,
project_id: formProjectId.value,
category: formCategory.value,
minutes: formMinutes.value && formMinutes.value > 0 ? formMinutes.value : null,
takeaway: formTakeaway.value.trim() || null,
remark: formRemark.value.trim() || null,
is_milestone: formMilestone.value ? 1 : 0,
needs_followup: formFollowup.value ? 1 : 0
})
}
</script>
<template>
<teleport to="body">
<transition name="modal">
<div v-if="open" class="mask" @click.self="emit('cancel')">
<div class="dialog" role="dialog" aria-label="编辑工作记录">
<div class="dialog-head">
<div>
<div class="dialog-title">编辑工作记录</div>
<div v-if="date" class="muted dialog-sub">{{ date }}</div>
</div>
<button class="icon-btn" title="关闭" @click="emit('cancel')">
<Icon name="close" :size="16" />
</button>
</div>
<div class="dialog-body">
<div class="form-grid">
<div class="field span2">
<label>工作内容(必填)</label>
<input
ref="contentInput" class="input" :class="{ invalid: contentError }"
v-model="formContent" @keydown.enter="save"
/>
<div v-if="contentError" class="form-error">{{ contentError }}</div>
</div>
<div class="field">
<label>所属项目</label>
<select class="select" v-model="formProjectId">
<option :value="null">无项目</option>
<option v-for="p in projectOptions" :key="p.id" :value="p.id">{{ p.name }}</option>
</select>
</div>
<div class="field">
<label>工作分类</label>
<select class="select" v-model="formCategory">
<option v-for="c in CATEGORIES" :key="c.key" :value="c.key">{{ c.label }}</option>
</select>
</div>
<div class="field">
<label>耗时(分钟)</label>
<input class="input" type="number" min="0" v-model.number="formMinutes" placeholder="选填" />
</div>
<div class="field">
<label>主要收获</label>
<input class="input" v-model="formTakeaway" placeholder="一句话:学到了什么" />
</div>
<div class="field span2">
<label>备注</label>
<textarea class="textarea" v-model="formRemark" placeholder="背景、链接、未决事项(选填)"></textarea>
</div>
<div class="field">
<label>成果标记</label>
<button
class="toggle-btn" :class="{ on: formMilestone === 1 }"
@click="formMilestone = formMilestone === 1 ? 0 : 1"
>{{ formMilestone === 1 ? '已标记为成果' : '未标记' }}</button>
</div>
<div class="field">
<label>待跟进</label>
<button
class="toggle-btn" :class="{ onwarn: formFollowup === 1 }"
@click="formFollowup = formFollowup === 1 ? 0 : 1"
>{{ formFollowup === 1 ? '需要跟进' : '无需跟进' }}</button>
</div>
</div>
</div>
<div class="dialog-btns">
<button class="btn btn-sm" @click="emit('cancel')">取消</button>
<button class="btn btn-primary btn-sm" @click="save">保存修改</button>
</div>
</div>
</div>
</transition>
</teleport>
</template>
<style scoped>
.mask {
position: fixed;
inset: 0;
background: rgba(29, 33, 41, 0.4);
display: flex;
align-items: center;
justify-content: center;
z-index: 90;
}
.dialog {
width: 560px;
max-width: calc(100vw - 32px);
max-height: 85vh;
background: var(--bg-page);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-float);
display: flex;
flex-direction: column;
}
.dialog-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
padding: 16px 20px 12px;
border-bottom: 1px solid var(--border);
flex: none;
}
.dialog-title { font-size: 14px; font-weight: 600; color: var(--text-1); }
.dialog-sub { font-size: 12px; margin-top: 2px; }
.dialog-body { padding: 16px 20px; overflow: auto; }
.dialog-btns {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 12px 20px 16px;
border-top: 1px solid var(--border);
flex: none;
}
.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px 16px; }
.field.span2 { grid-column: span 2; }
.field label { display: block; font-size: 12px; color: var(--text-2); margin-bottom: 4px; }
.form-error { color: var(--danger); font-size: 12px; margin-top: 4px; }
.input.invalid, .input.invalid:focus { border-color: var(--danger); }
.toggle-btn {
height: 32px;
width: 100%;
border: 1px solid var(--border-strong);
background: var(--bg-page);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 13px;
color: var(--text-2);
}
.toggle-btn:hover { background: var(--bg-hover); }
.toggle-btn.on { color: var(--accent); border-color: var(--accent); background: var(--accent-bg); }
.toggle-btn.onwarn { color: var(--warn); border-color: var(--warn); }
.modal-enter-active, .modal-leave-active { transition: opacity 0.15s ease; }
.modal-enter-from, .modal-leave-to { opacity: 0; }
</style>