初始提交:实习工作日志系统(设计方案 + Vue3 应用 + Electron 打包)
This commit is contained in:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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"/>')
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user