feat: 计划模式(时间段+打卡+计时时钟) / 工作详情范围导出 / 工作与记账分类自定义(含历史迁移)
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
# v0.4.0 发布记录
|
||||
|
||||
发布日期:2026-10-16
|
||||
|
||||
## 新功能:计划模式
|
||||
|
||||
- 新增「计划」页:按天规划时间段(开始/结束时间、类别、备注),侧边栏"记录"组新增入口,手机端底部标签直达
|
||||
- 条目级打卡:完成 / 跳过 / 恢复,日汇总(计划项数、完成数、计划时长、实际投入)
|
||||
- 计时时钟:任意条目一键开始/停止计时,自动累计实际投入分钟;同刻仅一个计时,开始新计时自动停止上一个
|
||||
- 计划数据进入同步体系,多端实时互通
|
||||
|
||||
## 新功能:范围化导出
|
||||
|
||||
- 设置 → 工作详情导出:当日 / 当周 / 当月 / 自定义时间段一键导出 CSV(含分类、项目、耗时、成果、待跟进、收获、备注)
|
||||
|
||||
## 新功能:分类自定义
|
||||
|
||||
- 工作记录分类改为用户自定义(默认:制度学习 / 产品学习 / 打杂 / 项目实操 / 售后服务),设置页可添加、改名、删除,随账号同步
|
||||
- 记账支出 / 收入分类同样自定义,记账弹窗内可直接"新增分类"
|
||||
- 历史数据自动迁移:旧固定分类(开发/BUG修复/学习成长…)按语义映射到新分类,报表与统计自动跟随
|
||||
|
||||
## 升级说明
|
||||
|
||||
- 服务器:cd /opt/daywork && git pull && cd server && docker compose up -d --build(需包含本次 plan 表的服务端支持)
|
||||
- 客户端覆盖安装即可,本地库自动升级到 v5 并完成分类迁移
|
||||
-26
File diff suppressed because one or more lines are too long
-1
File diff suppressed because one or more lines are too long
+26
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
+2
-2
@@ -1,2 +1,2 @@
|
||||
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["./web-Tgz3gT_A.js","./index-D8vVtKDY.js","./index-DxgZcHRE.css"])))=>i.map(i=>d[i]);
|
||||
import{r as s,_ as i}from"./index-D8vVtKDY.js";var t;(function(e){e.Documents="DOCUMENTS",e.Data="DATA",e.Library="LIBRARY",e.Cache="CACHE",e.External="EXTERNAL",e.ExternalStorage="EXTERNAL_STORAGE"})(t||(t={}));var a;(function(e){e.UTF8="utf8",e.ASCII="ascii",e.UTF16="utf16"})(a||(a={}));const E=s("Filesystem",{web:()=>i(()=>import("./web-Tgz3gT_A.js"),__vite__mapDeps([0,1,2]),import.meta.url).then(e=>new e.FilesystemWeb)});export{t as Directory,a as Encoding,E as Filesystem};
|
||||
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["./web-Eue2aiSv.js","./index-IuJhwbTi.js","./index-MT694d7P.css"])))=>i.map(i=>d[i]);
|
||||
import{r as s,_ as i}from"./index-IuJhwbTi.js";var t;(function(e){e.Documents="DOCUMENTS",e.Data="DATA",e.Library="LIBRARY",e.Cache="CACHE",e.External="EXTERNAL",e.ExternalStorage="EXTERNAL_STORAGE"})(t||(t={}));var a;(function(e){e.UTF8="utf8",e.ASCII="ascii",e.UTF16="utf16"})(a||(a={}));const E=s("Filesystem",{web:()=>i(()=>import("./web-Eue2aiSv.js"),__vite__mapDeps([0,1,2]),import.meta.url).then(e=>new e.FilesystemWeb)});export{t as Directory,a as Encoding,E as Filesystem};
|
||||
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -8,8 +8,8 @@
|
||||
<link rel="icon" href="./icons/icon.svg" type="image/svg+xml" />
|
||||
<link rel="apple-touch-icon" href="./icons/icon-192.png" />
|
||||
<title>实习工作日志</title>
|
||||
<script type="module" crossorigin src="./assets/index-D8vVtKDY.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-DxgZcHRE.css">
|
||||
<script type="module" crossorigin src="./assets/index-IuJhwbTi.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-MT694d7P.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "intern-log",
|
||||
"private": true,
|
||||
"version": "0.3.1",
|
||||
"version": "0.4.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -3,6 +3,7 @@ import { computed, onMounted, ref, watch } from 'vue'
|
||||
import Icon from './components/Icon.vue'
|
||||
import CalendarView from './views/CalendarView.vue'
|
||||
import TodayView from './views/TodayView.vue'
|
||||
import PlanView from './views/PlanView.vue'
|
||||
import StatsView from './views/StatsView.vue'
|
||||
import ReportView from './views/ReportView.vue'
|
||||
import LedgerView from './views/LedgerView.vue'
|
||||
@@ -112,6 +113,7 @@ function onGlobalKey(e: KeyboardEvent) {
|
||||
const VIEWS = [
|
||||
{ key: 'calendar', name: '日历', icon: 'calendar', group: '记录' },
|
||||
{ key: 'today', name: '详情', icon: 'edit', group: '记录' },
|
||||
{ key: 'plan', name: '计划', icon: 'plan', group: '记录' },
|
||||
{ key: 'ledger', name: '记账', icon: 'wallet', group: '工具' },
|
||||
{ key: 'memo', name: '备忘录', icon: 'memo', group: '工具' },
|
||||
{ key: 'stats', name: '统计', icon: 'chart', group: '分析' },
|
||||
@@ -145,7 +147,7 @@ watch([isAdminUser, route], () => {
|
||||
}, { immediate: true })
|
||||
|
||||
// 手机端底部标签页:4 个主功能 + 「更多」弹层(统计/总结/密码库/设置/退出)
|
||||
const TAB_KEYS = ['calendar', 'today', 'ledger', 'memo']
|
||||
const TAB_KEYS = ['calendar', 'today', 'plan', 'ledger']
|
||||
const TABS = VIEWS.filter(v => TAB_KEYS.includes(v.key))
|
||||
const moreOpen = ref(false)
|
||||
function navFromSheet(key: string) { moreOpen.value = false; nav(key) }
|
||||
@@ -291,6 +293,7 @@ onMounted(async () => {
|
||||
<div class="content-inner">
|
||||
<CalendarView v-if="route === 'calendar'" />
|
||||
<TodayView v-else-if="route === 'today'" />
|
||||
<PlanView v-else-if="route === 'plan'" />
|
||||
<StatsView v-else-if="route === 'stats'" />
|
||||
<ReportView v-else-if="route === 'report'" />
|
||||
<LedgerView v-else-if="route === 'ledger'" />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
|
||||
import Icon from './Icon.vue'
|
||||
import { EXPENSE_CATEGORIES, INCOME_CATEGORIES } from '../db/schema'
|
||||
import { getLedgerCategories, setLedgerCategories } from '../db/categories'
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
@@ -15,7 +15,7 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const fType = ref<'expense' | 'income'>('expense')
|
||||
const fCategory = ref('餐饮')
|
||||
const fCategory = ref('')
|
||||
const fAmount = ref('')
|
||||
const fDate = ref('')
|
||||
const fNote = ref('')
|
||||
@@ -30,6 +30,7 @@ function onKey(e: KeyboardEvent) {
|
||||
}
|
||||
watch(() => props.open, async v => {
|
||||
if (v) {
|
||||
await loadCats()
|
||||
fType.value = (props.initial.type === 'income' ? 'income' : 'expense')
|
||||
fCategory.value = props.initial.category
|
||||
fAmount.value = props.initial.amountYuan
|
||||
@@ -51,7 +52,20 @@ watch(fType, t => {
|
||||
fCategory.value = t === 'expense' ? EXPENSE_CATEGORIES[0] : INCOME_CATEGORIES[0]
|
||||
})
|
||||
|
||||
const categoryOptions = computed(() => (fType.value === 'expense' ? EXPENSE_CATEGORIES : INCOME_CATEGORIES))
|
||||
const categoryOptions = ref<string[]>([])
|
||||
async function loadCats() {
|
||||
categoryOptions.value = await getLedgerCategories(fType.value)
|
||||
}
|
||||
const newCat = ref('')
|
||||
async function addCategory() {
|
||||
const name = newCat.value.trim()
|
||||
if (!name) return
|
||||
if (categoryOptions.value.includes(name)) { newCat.value = ''; return }
|
||||
categoryOptions.value.push(name)
|
||||
await setLedgerCategories(fType.value, categoryOptions.value)
|
||||
fCategory.value = name
|
||||
newCat.value = ''
|
||||
}
|
||||
function computedCategory() {
|
||||
return (fCategory.value && categoryOptions.value.includes(fCategory.value)) ? fCategory.value : categoryOptions.value[0]
|
||||
}
|
||||
@@ -109,10 +123,14 @@ function save() {
|
||||
<input class="input" type="date" v-model="fDate" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>分类</label>
|
||||
<label>分类(可直接新增)</label>
|
||||
<select class="select" v-model="fCategory">
|
||||
<option v-for="c in categoryOptions" :key="c" :value="c">{{ c }}</option>
|
||||
</select>
|
||||
<div class="add-cat">
|
||||
<input class="input add-cat-input" v-model="newCat" placeholder="新增分类名" @keydown.enter.prevent="addCategory" />
|
||||
<button class="btn btn-xs" type="button" @click="addCategory">添加</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field" v-if="fType === 'expense'">
|
||||
<label>报销状态</label>
|
||||
@@ -164,6 +182,8 @@ function save() {
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.dialog-title { font-size: 14px; font-weight: 600; color: var(--text-1); }
|
||||
.add-cat { display: flex; gap: 6px; margin-top: 6px; }
|
||||
.add-cat-input { flex: 1; height: 28px; font-size: 12px; }
|
||||
.dialog-body { padding: 16px 20px; }
|
||||
.dialog-btns {
|
||||
display: flex;
|
||||
|
||||
@@ -24,5 +24,6 @@ export const ICONS: Record<string, string> = {
|
||||
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"/>'),
|
||||
wallet: svg('<rect x="3.5" y="6" width="17" height="12.5" rx="2"/><path d="M3.5 10h17"/><circle cx="16.4" cy="14.2" r="1.1"/>'),
|
||||
more: svg('<circle cx="5.4" cy="12" r="1.7"/><circle cx="12" cy="12" r="1.7"/><circle cx="18.6" cy="12" r="1.7"/>'),
|
||||
shield: svg('<path d="M12 3.5l7.2 2.7v5.1c0 4.7-3.1 7.8-7.2 9.4-4.1-1.6-7.2-4.7-7.2-9.4V6.2z"/><path d="M9.3 12.2l1.9 1.9 3.6-4"/>')
|
||||
shield: svg('<path d="M12 3.5l7.2 2.7v5.1c0 4.7-3.1 7.8-7.2 9.4-4.1-1.6-7.2-4.7-7.2-9.4V6.2z"/><path d="M9.3 12.2l1.9 1.9 3.6-4"/>'),
|
||||
plan: svg('<rect x="4.5" y="4" width="15" height="16.5" rx="2"/><path d="M8.5 9.5l1.4 1.4 2.6-2.9M8.5 15l1.4 1.4 2.6-2.9M15 9.8h.01M15 15.3h.01"/>')
|
||||
}
|
||||
|
||||
@@ -8,13 +8,16 @@ import {
|
||||
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 { categoryLabel, type Attendance, type LogItem, type Project } from '../../db/schema'
|
||||
import { getLogCategories } from '../../db/categories'
|
||||
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 logCats = ref<string[]>([])
|
||||
getLogCategories().then(l => { logCats.value = l })
|
||||
const props = defineProps<{ date: string }>()
|
||||
|
||||
const loading = ref(true)
|
||||
@@ -402,7 +405,7 @@ async function confirmDelete() {
|
||||
<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>
|
||||
<option v-for="c in logCats" :key="c" :value="c">{{ c }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
<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'
|
||||
import { type LogItem, type Project } from '../../db/schema'
|
||||
import { getLogCategories } from '../../db/categories'
|
||||
|
||||
const logCats = ref<string[]>([])
|
||||
getLogCategories().then(l => { logCats.value = l })
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
item: LogItem | null
|
||||
@@ -137,7 +140,7 @@ function save() {
|
||||
<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>
|
||||
<option v-for="c in logCats" :key="c" :value="c">{{ c }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
// 分类管理:工作分类与记账分类均为用户数据(存于服务器随账号同步)
|
||||
// 未自定义时返回默认值;分类值即显示文本(历史遗留 key 由 categoryLabel 直通转换)
|
||||
import { getSetting, setSetting } from './repo'
|
||||
|
||||
export const DEFAULT_LOG_CATEGORIES: string[] = ['制度学习', '产品学习', '打杂', '项目实操', '售后服务']
|
||||
export const DEFAULT_EXPENSE_CATEGORIES: string[] = ['餐饮', '交通', '住房', '通讯', '学习', '娱乐', '其他']
|
||||
export const DEFAULT_INCOME_CATEGORIES: string[] = ['工资', '报销', '其他']
|
||||
|
||||
const LOG_KEY = 'log_categories'
|
||||
const EXPENSE_KEY = 'ledger_expense_categories'
|
||||
const INCOME_KEY = 'ledger_income_categories'
|
||||
|
||||
function parse(raw: string, fallback: string[]): string[] {
|
||||
if (!raw) return fallback.slice()
|
||||
try {
|
||||
const arr = JSON.parse(raw)
|
||||
if (Array.isArray(arr)) {
|
||||
const clean = arr.filter((x: unknown) => typeof x === 'string' && x.trim()).map((x: string) => x.trim())
|
||||
if (clean.length) return Array.from(new Set(clean))
|
||||
}
|
||||
} catch { /* 损坏数据回退默认 */ }
|
||||
return fallback.slice()
|
||||
}
|
||||
|
||||
export async function getLogCategories(): Promise<string[]> {
|
||||
return parse(await getSetting(LOG_KEY, ''), DEFAULT_LOG_CATEGORIES)
|
||||
}
|
||||
|
||||
export async function setLogCategories(list: string[]): Promise<void> {
|
||||
await setSetting(LOG_KEY, JSON.stringify(list))
|
||||
}
|
||||
|
||||
export async function getLedgerCategories(type: 'expense' | 'income'): Promise<string[]> {
|
||||
return parse(await getSetting(type === 'expense' ? EXPENSE_KEY : INCOME_KEY, ''), type === 'expense' ? DEFAULT_EXPENSE_CATEGORIES : DEFAULT_INCOME_CATEGORIES)
|
||||
}
|
||||
|
||||
export async function setLedgerCategories(type: 'expense' | 'income', list: string[]): Promise<void> {
|
||||
await setSetting(type === 'expense' ? EXPENSE_KEY : INCOME_KEY, JSON.stringify(list))
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
// 数据仓库:全部业务数据只有服务器一份权威副本,本地 Dexie 是实时缓存。
|
||||
// 约定:每个写函数修改本地缓存后立即调用 commit() —— 推送差异给服务器,
|
||||
// 并以服务器返回的权威快照重建缓存;断网时抛出错误(纯在线模式)。
|
||||
import { db, DEFAULT_LOCATIONS, SCHEMA_VERSION, type Attendance, type Credential, type LogItem, type Memo, type Project, type Transaction, type WeeklyNote, type WorkLog } from './schema'
|
||||
import { db, DEFAULT_LOCATIONS, SCHEMA_VERSION, type Attendance, type Credential, type LogItem, type Memo, type PlanItem, type Project, type Transaction, type WeeklyNote, type WorkLog } from './schema'
|
||||
import { addDays, nowLocalISO, toDateStr } from '../lib/date'
|
||||
import { flushAfterWrite } from '../sync/engine'
|
||||
|
||||
@@ -360,6 +360,84 @@ export async function listWeeklyNotesBetween(start: string, end: string): Promis
|
||||
return db.weekly_note.where('week_start').between(start, end, true, true).toArray()
|
||||
}
|
||||
|
||||
// ---------- 计划模式 ----------
|
||||
|
||||
export interface PlanInput {
|
||||
date: string
|
||||
start_time: string
|
||||
end_time: string
|
||||
title: string
|
||||
kind: string
|
||||
note: string
|
||||
}
|
||||
|
||||
export async function listPlansByDate(date: string): Promise<PlanItem[]> {
|
||||
const rows = await db.plan.where('date').equals(date).toArray()
|
||||
return rows.sort((a, b) => (a.start_time < b.start_time ? -1 : 1))
|
||||
}
|
||||
|
||||
export async function addPlan(input: PlanInput): Promise<void> {
|
||||
const now = nowLocalISO()
|
||||
await db.plan.add({
|
||||
date: input.date,
|
||||
start_time: input.start_time,
|
||||
end_time: input.end_time,
|
||||
title: input.title.trim(),
|
||||
kind: input.kind.trim() || '其他',
|
||||
note: input.note || null,
|
||||
status: 'todo',
|
||||
actual_minutes: 0,
|
||||
timer_started_at: null,
|
||||
uuid: '',
|
||||
created_at: now,
|
||||
updated_at: now
|
||||
} as never)
|
||||
await commit()
|
||||
}
|
||||
|
||||
export async function updatePlan(id: number, patch: Partial<PlanItem>): Promise<void> {
|
||||
await db.plan.update(id, { ...patch, updated_at: nowLocalISO() })
|
||||
await commit()
|
||||
}
|
||||
|
||||
export async function deletePlan(id: number): Promise<void> {
|
||||
await db.plan.delete(id)
|
||||
await commit()
|
||||
}
|
||||
|
||||
// 打卡:完成 / 跳过 / 恢复
|
||||
export async function setPlanStatus(id: number, status: 'todo' | 'done' | 'skip'): Promise<void> {
|
||||
const row = await db.plan.get(id)
|
||||
if (!row) return
|
||||
if (status !== 'todo' && row.timer_started_at) {
|
||||
await stopPlanTimer(id)
|
||||
}
|
||||
await db.plan.update(id, { status, updated_at: nowLocalISO() })
|
||||
await commit()
|
||||
}
|
||||
|
||||
// 计时时钟:同一时刻只允许一个条目在计时,开始新计时会自动停止上一个
|
||||
export async function startPlanTimer(id: number): Promise<void> {
|
||||
const running = await db.plan.filter(p => !!p.timer_started_at).toArray()
|
||||
for (const p of running) {
|
||||
if (p.id !== id) await stopPlanTimer(p.id as number)
|
||||
}
|
||||
await db.plan.update(id, { timer_started_at: nowLocalISO(), updated_at: nowLocalISO() })
|
||||
await commit()
|
||||
}
|
||||
|
||||
export async function stopPlanTimer(id: number): Promise<void> {
|
||||
const row = await db.plan.get(id)
|
||||
if (!row || !row.timer_started_at) return
|
||||
const elapsed = Math.max(0, Math.round((Date.now() - new Date(row.timer_started_at).getTime()) / 60000))
|
||||
await db.plan.update(id, {
|
||||
actual_minutes: (row.actual_minutes || 0) + elapsed,
|
||||
timer_started_at: null,
|
||||
updated_at: nowLocalISO()
|
||||
})
|
||||
await commit()
|
||||
}
|
||||
|
||||
// ---------- 备忘录 ----------
|
||||
|
||||
export async function listMemos(): Promise<Memo[]> {
|
||||
@@ -457,7 +535,8 @@ export async function exportAll(): Promise<string> {
|
||||
db.setting.toArray(),
|
||||
db.memo.toArray(),
|
||||
db.credential.toArray(),
|
||||
db.table('transaction').toArray()
|
||||
db.table('transaction').toArray(),
|
||||
db.plan.toArray()
|
||||
])
|
||||
return JSON.stringify({
|
||||
schema_version: SCHEMA_VERSION,
|
||||
@@ -471,7 +550,8 @@ export async function exportAll(): Promise<string> {
|
||||
setting: tables[5],
|
||||
memo: tables[6],
|
||||
credential: tables[7],
|
||||
transaction: tables[8]
|
||||
transaction: tables[8],
|
||||
plan: tables[9]
|
||||
}
|
||||
}, null, 2)
|
||||
}
|
||||
@@ -483,17 +563,18 @@ export async function importAll(jsonText: string): Promise<void> {
|
||||
} catch {
|
||||
throw new Error('不是有效的 JSON 文件')
|
||||
}
|
||||
if (!parsed || (parsed.schema_version !== 1 && parsed.schema_version !== 2 && parsed.schema_version !== 3) || !parsed.data) {
|
||||
if (!parsed || (parsed.schema_version !== 1 && parsed.schema_version !== 2 && parsed.schema_version !== 3 && parsed.schema_version !== 4 && parsed.schema_version !== 5) || !parsed.data) {
|
||||
throw new Error('文件格式或 schema 版本不匹配')
|
||||
}
|
||||
const d = parsed.data
|
||||
const dl = db.table('log_item')
|
||||
const dt = db.table('transaction')
|
||||
await db.transaction('rw', [db.project, db.work_log, dl, db.attendance, db.weekly_note, db.setting, db.memo, db.credential, dt], async () => {
|
||||
const dp = db.plan
|
||||
await db.transaction('rw', [db.project, db.work_log, dl, db.attendance, db.weekly_note, db.setting, db.memo, db.credential, dt, dp], async () => {
|
||||
await Promise.all([
|
||||
db.project.clear(), db.work_log.clear(), dl.clear(),
|
||||
db.attendance.clear(), db.weekly_note.clear(), db.setting.clear(),
|
||||
db.memo.clear(), db.credential.clear(), dt.clear()
|
||||
db.memo.clear(), db.credential.clear(), dt.clear(), dp.clear()
|
||||
])
|
||||
if (d.project && d.project.length) await db.project.bulkAdd(d.project as never[])
|
||||
if (d.work_log && d.work_log.length) await db.work_log.bulkAdd(d.work_log as never[])
|
||||
@@ -504,6 +585,7 @@ export async function importAll(jsonText: string): Promise<void> {
|
||||
if (d.memo && d.memo.length) await db.memo.bulkAdd(d.memo as never[])
|
||||
if (d.credential && d.credential.length) await db.credential.bulkAdd(d.credential as never[])
|
||||
if (d.transaction && d.transaction.length) await dt.bulkAdd(d.transaction as never[])
|
||||
if (d.plan && d.plan.length) await dp.bulkAdd(d.plan as never[])
|
||||
})
|
||||
// 导入即推送:以导入内容覆盖服务器(缺失行自动生成墓碑清除),随后以快照回读
|
||||
await commit()
|
||||
|
||||
+47
-12
@@ -48,6 +48,22 @@ export interface WeeklyNote {
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface PlanItem {
|
||||
id?: number
|
||||
date: string // YYYY-MM-DD
|
||||
start_time: string // HH:mm
|
||||
end_time: string // HH:mm
|
||||
title: string // 计划内容,如:产品学习 / 健身
|
||||
kind: string // 类别:学习 / 健身 / 自律 / 其他自定义
|
||||
note: string | null
|
||||
status: string // todo | done | skip
|
||||
actual_minutes: number // 计时时钟累计的实际投入分钟
|
||||
timer_started_at: string | null
|
||||
uuid: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface Memo {
|
||||
id?: number
|
||||
title: string
|
||||
@@ -107,6 +123,7 @@ export class WorklogDB extends Dexie {
|
||||
// 因为 db.transaction 会与 Dexie 内置的 transaction() 方法冲突
|
||||
sync_mirror!: Table<SyncMirror, number>
|
||||
tombstone!: Table<SyncTombstone, number>
|
||||
plan!: Table<PlanItem, number>
|
||||
|
||||
constructor(dbName = 'intern_worklog') {
|
||||
super(dbName)
|
||||
@@ -129,6 +146,23 @@ export class WorklogDB extends Dexie {
|
||||
sync_mirror: '++id, table, uuid',
|
||||
tombstone: '++id, table, uuid, deleted_at'
|
||||
})
|
||||
this.version(5).stores({
|
||||
plan: '++id, date, status'
|
||||
}).upgrade(async tx => {
|
||||
// 旧固定分类 key 迁移为用户自定义分类标签(v0.4.0 起分类为用户数据)
|
||||
const map: Record<string, string> = {
|
||||
dev: '项目实操',
|
||||
test: '项目实操',
|
||||
bugfix: '售后服务',
|
||||
learning: '产品学习',
|
||||
meeting: '打杂',
|
||||
doc: '打杂',
|
||||
other: '打杂'
|
||||
}
|
||||
await tx.table('log_item').toCollection().modify((item: { category?: string }) => {
|
||||
if (item.category && map[item.category]) item.category = map[item.category]
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,24 +178,25 @@ export function unbindDatabase(): void {
|
||||
db = undefined
|
||||
}
|
||||
|
||||
export const SCHEMA_VERSION = 4
|
||||
export const SCHEMA_VERSION = 5
|
||||
|
||||
export const EXPENSE_CATEGORIES: string[] = ['餐饮', '交通', '住房', '通讯', '学习', '娱乐', '其他']
|
||||
export const INCOME_CATEGORIES: string[] = ['工资', '报销', '其他']
|
||||
|
||||
export const DEFAULT_LOCATIONS: string[] = ['公司', '客户现场', '出差', '居家办公']
|
||||
|
||||
export const CATEGORIES: { key: string; label: string }[] = [
|
||||
{ key: 'dev', label: '开发' },
|
||||
{ key: 'bugfix', label: 'BUG修复' },
|
||||
{ key: 'test', label: '测试' },
|
||||
{ key: 'learning', label: '学习成长' },
|
||||
{ key: 'meeting', label: '会议沟通' },
|
||||
{ key: 'doc', label: '文档输出' },
|
||||
{ key: 'other', label: '其他' }
|
||||
]
|
||||
// 旧版固定分类 key → 默认标签的映射(仅用于历史数据迁移与展示兜底)
|
||||
export const LEGACY_CATEGORY_LABELS: Record<string, string> = {
|
||||
dev: '项目实操',
|
||||
test: '项目实操',
|
||||
bugfix: '售后服务',
|
||||
learning: '产品学习',
|
||||
meeting: '打杂',
|
||||
doc: '打杂',
|
||||
other: '打杂'
|
||||
}
|
||||
|
||||
// v0.4.0 起分类即用户自定义标签本身;历史 key 在此直通转换
|
||||
export function categoryLabel(key: string): string {
|
||||
const found = CATEGORIES.find(c => c.key === key)
|
||||
return found ? found.label : '其他'
|
||||
return LEGACY_CATEGORY_LABELS[key] || key
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { getWorkRule, listAttendanceBetween, listLogItemsBetween, listProjects, listWeeklyNotesBetween } from '../db/repo'
|
||||
import { CATEGORIES, type LogItem } from '../db/schema'
|
||||
import { type LogItem } from '../db/schema'
|
||||
import { getLogCategories } from '../db/categories'
|
||||
import { todayStr } from '../lib/date'
|
||||
import { computeDay } from './attendance'
|
||||
|
||||
@@ -44,13 +45,15 @@ export async function buildReport(start: string, end: string, label: string): Pr
|
||||
listAttendanceBetween(start, end),
|
||||
listLogItemsBetween(start, end),
|
||||
listProjects(true),
|
||||
listWeeklyNotesBetween(start, end)
|
||||
listWeeklyNotesBetween(start, end),
|
||||
getLogCategories()
|
||||
])
|
||||
const rule = results[0]
|
||||
const atts = results[1]
|
||||
const items = results[2]
|
||||
const projects = results[3]
|
||||
const notes = results[4]
|
||||
const logCats: string[] = results[5] as string[]
|
||||
|
||||
let attendDays = 0
|
||||
let workedMin = 0
|
||||
@@ -73,10 +76,14 @@ export async function buildReport(start: string, end: string, label: string): Pr
|
||||
.sort((a, b) => b.count - a.count)
|
||||
|
||||
const total = items.length || 1
|
||||
const categoryStats: CategoryStat[] = CATEGORIES
|
||||
.map(c => {
|
||||
const count = items.filter((i: LogItem) => i.category === c.key).length
|
||||
return { key: c.key, label: c.label, count, percent: Math.round((count / total) * 100) }
|
||||
const catCount2 = new Map<string, number>()
|
||||
for (const it of items) catCount2.set(it.category, (catCount2.get(it.category) || 0) + 1)
|
||||
const catSet2 = new Set(logCats)
|
||||
for (const k of catCount2.keys()) if (!catSet2.has(k)) logCats.push(k)
|
||||
const categoryStats: CategoryStat[] = logCats
|
||||
.map(label => {
|
||||
const count = catCount2.get(label) || 0
|
||||
return { key: label, label, count, percent: Math.round((count / total) * 100) }
|
||||
})
|
||||
.filter(c => c.count > 0)
|
||||
.sort((a, b) => b.count - a.count)
|
||||
|
||||
@@ -13,7 +13,7 @@ import { DEFAULT_SYNC_BASE, getSyncBase as baseOf, getToken, setToken, setSyncBa
|
||||
// 最近一次自动同步的错误(设置页展示用)
|
||||
export const syncLastError = ref('')
|
||||
|
||||
const TABLES = ['project', 'work_log', 'log_item', 'attendance', 'weekly_note', 'setting', 'memo', 'credential', 'transaction']
|
||||
const TABLES = ['project', 'work_log', 'log_item', 'attendance', 'weekly_note', 'setting', 'memo', 'credential', 'transaction', 'plan']
|
||||
|
||||
// 本地临时性键不参与同步,也不能从服务端写回本地缓存
|
||||
const EXCLUDE_SETTING_KEYS = new Set(['sync_cursor', 'sync_last_time', 'sync_token', 'last_project', 'last_category'])
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
addTransaction, deleteTransaction, listTransactionsBetween,
|
||||
setTransactionReimbursed, updateTransaction, type Transaction
|
||||
} from '../db/repo'
|
||||
import { EXPENSE_CATEGORIES, INCOME_CATEGORIES } from '../db/schema'
|
||||
import { getLedgerCategories } from '../db/categories'
|
||||
import { addMonths, todayStr } from '../lib/date'
|
||||
import { showToast } from '../lib/toast'
|
||||
|
||||
@@ -20,7 +20,7 @@ const loading = ref(true)
|
||||
// ---- 新增表单 ----
|
||||
const fType = ref<TxType>('expense')
|
||||
const fAmount = ref('')
|
||||
const fCategory = ref(EXPENSE_CATEGORIES[0])
|
||||
const fCategory = ref('')
|
||||
const fDate = ref(todayStr())
|
||||
const fNote = ref('')
|
||||
const fReimb = ref(false)
|
||||
@@ -30,7 +30,7 @@ const submitting = ref(false)
|
||||
|
||||
const dialogOpen = ref(false)
|
||||
const editingId = ref<number | null>(null)
|
||||
const dialogInitial = ref({ type: 'expense', category: EXPENSE_CATEGORIES[0], amountYuan: '', date: todayStr(), note: '', reimbursable: 0, reimbursed: 0 })
|
||||
const dialogInitial = ref({ type: 'expense', category: '', amountYuan: '', date: todayStr(), note: '', reimbursable: 0, reimbursed: 0 })
|
||||
const deleteTarget = ref<Transaction | null>(null)
|
||||
|
||||
const monthStart = computed(() => monthRef.value.slice(0, 7) + '-01')
|
||||
@@ -56,8 +56,17 @@ async function load() {
|
||||
onMounted(load)
|
||||
watch(monthRef, load)
|
||||
|
||||
const expenseCats = ref<string[]>([])
|
||||
const incomeCats = ref<string[]>([])
|
||||
async function loadCats() {
|
||||
expenseCats.value = await getLedgerCategories('expense')
|
||||
incomeCats.value = await getLedgerCategories('income')
|
||||
if (fType.value === 'expense' && !expenseCats.value.includes(fCategory.value)) fCategory.value = expenseCats.value[0] || ''
|
||||
if (fType.value === 'income' && !incomeCats.value.includes(fCategory.value)) fCategory.value = incomeCats.value[0] || ''
|
||||
}
|
||||
loadCats()
|
||||
watch(fType, t => {
|
||||
fCategory.value = t === 'expense' ? EXPENSE_CATEGORIES[0] : INCOME_CATEGORIES[0]
|
||||
fCategory.value = t === 'expense' ? (expenseCats.value[0] || '') : (incomeCats.value[0] || '')
|
||||
})
|
||||
|
||||
const sorted = computed(() =>
|
||||
@@ -249,7 +258,7 @@ async function confirmDelete() {
|
||||
<div class="field">
|
||||
<label>分类</label>
|
||||
<select class="select" v-model="fCategory">
|
||||
<option v-for="c in (fType === 'expense' ? EXPENSE_CATEGORIES : INCOME_CATEGORIES)" :key="c" :value="c">{{ c }}</option>
|
||||
<option v-for="c in (fType === 'expense' ? expenseCats : incomeCats)" :key="c" :value="c">{{ c }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field" v-if="fType === 'expense'">
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import Icon from '../components/Icon.vue'
|
||||
import {
|
||||
addPlan, deletePlan, listPlansByDate, setPlanStatus,
|
||||
startPlanTimer, stopPlanTimer, updatePlan, type PlanItem
|
||||
} from '../db/repo'
|
||||
import { addDays, parseDateStr, todayStr, toDateStr, formatDateCN } from '../lib/date'
|
||||
import { attendanceChanged } from '../lib/bus'
|
||||
import { showToast } from '../lib/toast'
|
||||
|
||||
const date = ref(todayStr())
|
||||
const items = ref<PlanItem[]>([])
|
||||
const loading = ref(true)
|
||||
const now = ref(Date.now())
|
||||
let tick: number | null = null
|
||||
|
||||
const title = computed(() => formatDateCN(date.value))
|
||||
const isToday = computed(() => date.value === todayStr())
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
items.value = await listPlansByDate(date.value)
|
||||
loading.value = false
|
||||
}
|
||||
watch(date, load)
|
||||
watch(attendanceChanged, load)
|
||||
onMounted(() => {
|
||||
load()
|
||||
tick = window.setInterval(() => { now.value = Date.now() }, 30000)
|
||||
})
|
||||
onUnmounted(() => { if (tick) window.clearInterval(tick) })
|
||||
|
||||
function prevDay() { date.value = addDays(date.value, -1) }
|
||||
function nextDay() { date.value = addDays(date.value, 1) }
|
||||
function goToday() { date.value = todayStr() }
|
||||
|
||||
// ---- 汇总 ----
|
||||
const summary = computed(() => {
|
||||
const total = items.value.length
|
||||
const done = items.value.filter(p => p.status === 'done').length
|
||||
const plannedMin = items.value.reduce((acc, p) => acc + spanMinutes(p), 0)
|
||||
const actualMin = items.value.reduce((acc, p) => acc + (p.actual_minutes || 0), 0)
|
||||
const runningId = running.value
|
||||
if (runningId) {
|
||||
const rp = items.value.find(p => p.id === runningId)
|
||||
if (rp) actualMin + elapsedMinutes(rp)
|
||||
}
|
||||
return { total, done, plannedMin, actualMin }
|
||||
})
|
||||
function spanMinutes(p: PlanItem): number {
|
||||
const [sh, sm] = p.start_time.split(':').map(Number)
|
||||
const [eh, em] = p.end_time.split(':').map(Number)
|
||||
return Math.max(0, (eh * 60 + em) - (sh * 60 + sm))
|
||||
}
|
||||
function fmtMin(n: number): string {
|
||||
if (n < 60) return n + ' 分钟'
|
||||
return Math.floor(n / 60) + ' 小时 ' + (n % 60) + ' 分'
|
||||
}
|
||||
|
||||
// ---- 计时时钟 ----
|
||||
const running = computed(() => {
|
||||
const r = items.value.find(p => !!p.timer_started_at)
|
||||
return r ? (r.id as number) : null
|
||||
})
|
||||
function liveElapsed(p: PlanItem): number {
|
||||
if (!p.timer_started_at) return p.actual_minutes || 0
|
||||
const extra = Math.max(0, Math.round((now.value - new Date(p.timer_started_at).getTime()) / 60000))
|
||||
return (p.actual_minutes || 0) + extra
|
||||
}
|
||||
async function toggleTimer(p: PlanItem) {
|
||||
if (p.timer_started_at) {
|
||||
await stopPlanTimer(p.id as number)
|
||||
showToast('已停止计时:' + p.title)
|
||||
} else {
|
||||
await startPlanTimer(p.id as number)
|
||||
showToast('开始计时:' + p.title)
|
||||
}
|
||||
await load()
|
||||
}
|
||||
|
||||
// ---- 打卡 ----
|
||||
async function mark(p: PlanItem, status: 'todo' | 'done' | 'skip') {
|
||||
await setPlanStatus(p.id as number, status)
|
||||
await load()
|
||||
}
|
||||
|
||||
// ---- 新增 / 编辑 ----
|
||||
const dialogOpen = ref(false)
|
||||
const editingId = ref<number | null>(null)
|
||||
const fTitle = ref('')
|
||||
const fKind = ref('学习')
|
||||
const fStart = ref('09:00')
|
||||
const fEnd = ref('10:00')
|
||||
const fNote = ref('')
|
||||
const fError = ref('')
|
||||
const KINDS = ['学习', '健身', '自律', '工作', '阅读', '其他']
|
||||
|
||||
function openCreate() {
|
||||
editingId.value = null
|
||||
fTitle.value = ''
|
||||
fKind.value = '学习'
|
||||
fStart.value = '09:00'
|
||||
fEnd.value = '10:00'
|
||||
fNote.value = ''
|
||||
fError.value = ''
|
||||
dialogOpen.value = true
|
||||
}
|
||||
function openEdit(p: PlanItem) {
|
||||
editingId.value = p.id as number
|
||||
fTitle.value = p.title
|
||||
fKind.value = p.kind
|
||||
fStart.value = p.start_time
|
||||
fEnd.value = p.end_time
|
||||
fNote.value = p.note || ''
|
||||
fError.value = ''
|
||||
dialogOpen.value = true
|
||||
}
|
||||
async function submitPlan() {
|
||||
if (!fTitle.value.trim()) { fError.value = '请填写计划内容'; return }
|
||||
if (fEnd.value <= fStart.value) { fError.value = '结束时间需晚于开始时间'; return }
|
||||
if (editingId.value) {
|
||||
await updatePlan(editingId.value, {
|
||||
title: fTitle.value.trim(), kind: fKind.value, start_time: fStart.value,
|
||||
end_time: fEnd.value, note: fNote.value
|
||||
})
|
||||
showToast('计划已更新')
|
||||
} else {
|
||||
await addPlan({ date: date.value, start_time: fStart.value, end_time: fEnd.value, title: fTitle.value, kind: fKind.value, note: fNote.value })
|
||||
showToast('计划已添加')
|
||||
}
|
||||
dialogOpen.value = false
|
||||
await load()
|
||||
}
|
||||
|
||||
async function remove(p: PlanItem) {
|
||||
await deletePlan(p.id as number)
|
||||
showToast('已删除')
|
||||
await load()
|
||||
}
|
||||
|
||||
const wdNames = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
|
||||
function weekday(s: string): string {
|
||||
return wdNames[parseDateStr(s).getDay()]
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="plan-page">
|
||||
<div class="card sec plan-head">
|
||||
<div class="nav-row">
|
||||
<button class="icon-btn" @click="prevDay"><Icon name="chevron-left" :size="16" /></button>
|
||||
<div class="date-box">
|
||||
<span class="date-main num">{{ date.slice(5) }} {{ weekday(date) }}</span>
|
||||
<span class="muted date-sub num">{{ date }}</span>
|
||||
</div>
|
||||
<button class="icon-btn" @click="nextDay"><Icon name="chevron-right" :size="16" /></button>
|
||||
<button class="btn btn-sm" :class="{ 'btn-primary': !isToday }" @click="goToday">今天</button>
|
||||
<span class="flex1"></span>
|
||||
<button class="btn btn-primary btn-sm" @click="openCreate"><Icon name="plus" :size="14" /> 添加计划</button>
|
||||
</div>
|
||||
<div class="muted sum-line">
|
||||
共 {{ summary.total }} 项 · 完成 {{ summary.done }} 项 · 计划 {{ fmtMin(summary.plannedMin) }} · 实际投入 {{ fmtMin(summary.actualMin) }}
|
||||
<template v-if="running"> · <span class="timer-live">计时中</span></template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="plan-list">
|
||||
<div v-if="!loading && !items.length" class="card sec empty muted">
|
||||
今天还没有计划,点右上角「添加计划」开始规划:学习、健身、自律……
|
||||
</div>
|
||||
<div
|
||||
v-for="p in items" :key="p.id"
|
||||
class="card sec plan-item"
|
||||
:class="{ done: p.status === 'done', skip: p.status === 'skip', running: !!p.timer_started_at }"
|
||||
>
|
||||
<div class="pi-time num">
|
||||
{{ p.start_time }}<span class="muted"> - {{ p.end_time }}</span>
|
||||
</div>
|
||||
<div class="pi-main">
|
||||
<div class="pi-title">
|
||||
<span class="tag">{{ p.kind }}</span>
|
||||
<span class="pi-text" :class="{ strike: p.status === 'done' }">{{ p.title }}</span>
|
||||
<span v-if="p.status === 'done'" class="state-done">已完成</span>
|
||||
<span v-else-if="p.status === 'skip'" class="state-skip">已跳过</span>
|
||||
</div>
|
||||
<div v-if="p.note" class="muted pi-note">{{ p.note }}</div>
|
||||
<div class="muted pi-meta num">
|
||||
<template v-if="p.timer_started_at">
|
||||
<span class="timer-live">计时中 · 已 {{ liveElapsed(p) }} 分钟</span>
|
||||
</template>
|
||||
<template v-else-if="p.actual_minutes > 0">实际投入 {{ p.actual_minutes }} 分钟</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pi-ops">
|
||||
<button
|
||||
v-if="p.status !== 'done'" class="btn btn-xs"
|
||||
:class="{ 'timer-on': !!p.timer_started_at }"
|
||||
@click="toggleTimer(p)"
|
||||
>{{ p.timer_started_at ? '停止计时' : '计时' }}</button>
|
||||
<button v-if="p.status !== 'done'" class="btn btn-xs" @click="mark(p, 'done')">完成</button>
|
||||
<button v-if="p.status === 'todo'" class="btn btn-xs" @click="mark(p, 'skip')">跳过</button>
|
||||
<button v-if="p.status !== 'todo'" class="btn btn-xs" @click="mark(p, 'todo')">恢复</button>
|
||||
<button class="icon-btn" title="编辑" @click="openEdit(p)"><Icon name="edit" :size="14" /></button>
|
||||
<button class="icon-btn" title="删除" @click="remove(p)"><Icon name="close" :size="14" /></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<teleport to="body">
|
||||
<div v-if="dialogOpen" class="fixed-mask" @click.self="dialogOpen = false">
|
||||
<div class="dialog" role="dialog" aria-label="计划">
|
||||
<div class="dialog-head">
|
||||
<span class="dialog-title">{{ editingId ? '编辑计划' : '添加计划' }}</span>
|
||||
<button class="icon-btn" @click="dialogOpen = false"><Icon name="close" :size="16" /></button>
|
||||
</div>
|
||||
<div class="dialog-body">
|
||||
<div class="form-row">
|
||||
<label>计划内容(学什么、做什么)</label>
|
||||
<input class="input" v-model="fTitle" placeholder="如:产品文档第三章 / 晨跑 5 公里" @keydown.enter="submitPlan" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>类别</label>
|
||||
<select class="select" v-model="fKind">
|
||||
<option v-for="k in KINDS" :key="k" :value="k">{{ k }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-row two">
|
||||
<div>
|
||||
<label>开始时间</label>
|
||||
<input class="input" type="time" v-model="fStart" />
|
||||
</div>
|
||||
<div>
|
||||
<label>结束时间</label>
|
||||
<input class="input" type="time" v-model="fEnd" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>备注(选填)</label>
|
||||
<input class="input" v-model="fNote" placeholder="目标、资料、提醒等" @keydown.enter="submitPlan" />
|
||||
</div>
|
||||
<div v-if="fError" class="form-error">{{ fError }}</div>
|
||||
</div>
|
||||
<div class="dialog-btns">
|
||||
<button class="btn btn-sm" @click="dialogOpen = false">取消</button>
|
||||
<button class="btn btn-primary btn-sm" @click="submitPlan">{{ editingId ? '保存' : '添加' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.plan-page { display: flex; flex-direction: column; gap: 10px; max-width: 760px; }
|
||||
.plan-head { padding: 12px 16px; }
|
||||
.nav-row { display: flex; align-items: center; gap: 8px; }
|
||||
.date-box { min-width: 150px; text-align: center; }
|
||||
.date-main { font-weight: 600; font-size: 14px; }
|
||||
.date-sub { font-size: 11px; margin-left: 6px; }
|
||||
.sum-line { font-size: 12px; margin-top: 8px; }
|
||||
.timer-live { color: var(--accent); font-weight: 600; }
|
||||
.flex1 { flex: 1; }
|
||||
|
||||
.plan-item { display: flex; align-items: flex-start; gap: 12px; padding: 12px 14px; }
|
||||
.plan-item.done { opacity: 0.62; }
|
||||
.plan-item.skip { opacity: 0.5; }
|
||||
.plan-item.running { border-left: 3px solid var(--accent); }
|
||||
.pi-time { font-size: 13px; font-weight: 600; min-width: 92px; padding-top: 2px; }
|
||||
.pi-main { flex: 1; min-width: 0; }
|
||||
.pi-title { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.pi-text { font-size: 14px; font-weight: 500; word-break: break-all; }
|
||||
.pi-text.strike { text-decoration: line-through; }
|
||||
.state-done { color: var(--ok, #2e9e5b); font-size: 12px; }
|
||||
.state-skip { color: var(--text-3); font-size: 12px; }
|
||||
.pi-note { font-size: 12px; margin-top: 3px; }
|
||||
.pi-meta { font-size: 12px; margin-top: 3px; }
|
||||
.pi-ops { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; justify-content: flex-end; }
|
||||
.timer-on { color: var(--accent); border-color: var(--accent); }
|
||||
.empty { text-align: center; padding: 26px 16px; }
|
||||
|
||||
.fixed-mask {
|
||||
position: fixed; inset: 0; z-index: 80;
|
||||
background: rgba(29, 33, 41, 0.35);
|
||||
display: flex; align-items: center; justify-content: center; padding: 16px;
|
||||
}
|
||||
.dialog { background: var(--bg-page); border-radius: 12px; width: 420px; max-width: 100%; }
|
||||
.dialog-head { display: flex; align-items: center; justify-content: space-between; padding: 14px 16px 0; }
|
||||
.dialog-title { font-weight: 600; font-size: 14px; }
|
||||
.dialog-body { padding: 6px 16px 4px; }
|
||||
.dialog-btns { display: flex; justify-content: flex-end; gap: 10px; padding: 12px 16px 16px; }
|
||||
.form-row { display: flex; flex-direction: column; gap: 5px; margin-top: 10px; }
|
||||
.form-row label { font-size: 12px; color: var(--text-2); }
|
||||
.form-row.two { flex-direction: row; gap: 10px; }
|
||||
.form-row.two > div { flex: 1; display: flex; flex-direction: column; gap: 5px; }
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.plan-item { flex-wrap: wrap; }
|
||||
.pi-ops { width: 100%; justify-content: flex-start; }
|
||||
}
|
||||
</style>
|
||||
@@ -7,10 +7,11 @@ import {
|
||||
listAttendanceBetween, listLogItemsBetween, listProjects,
|
||||
renameProject, saveLocationPresets, setProjectArchived, setSetting, type WorkRule
|
||||
} from '../db/repo'
|
||||
import { categoryLabel, type Project } from '../db/schema'
|
||||
import { type Project } from '../db/schema'
|
||||
import { getLogCategories, setLogCategories, getLedgerCategories, setLedgerCategories } from '../db/categories'
|
||||
import { downloadText, } from '../lib/download'
|
||||
import { downloadCsv } from '../lib/csv'
|
||||
import { hhmm, parseDateStr, todayStr } from '../lib/date'
|
||||
import { addDays, hhmm, parseDateStr, todayStr, weekStart } from '../lib/date'
|
||||
import { computeDay } from '../domain/attendance'
|
||||
import { changeLoginPassword, deleteCurrentUser, getSession } from '../db/auth'
|
||||
import { ensureCloudBinding, getSyncLastTime, isSyncConfigured, syncWithStoredConfig, syncLastError, type SyncResult } from '../sync/engine'
|
||||
@@ -27,6 +28,85 @@ const aiKey = ref('')
|
||||
const aiModel = ref('')
|
||||
const editingId = ref<number | null>(null)
|
||||
const editName = ref('')
|
||||
// ---- 分类管理 ----
|
||||
const logCats = ref<string[]>([])
|
||||
const expenseCats = ref<string[]>([])
|
||||
const incomeCats = ref<string[]>([])
|
||||
const editingCat = ref<{ kind: 'log' | 'expense' | 'income'; index: number; value: string } | null>(null)
|
||||
|
||||
async function loadCategories() {
|
||||
logCats.value = await getLogCategories()
|
||||
expenseCats.value = await getLedgerCategories('expense')
|
||||
incomeCats.value = await getLedgerCategories('income')
|
||||
}
|
||||
async function addCat(kind: 'log' | 'expense' | 'income') {
|
||||
const name = window.prompt('新分类名称')
|
||||
if (!name || !name.trim()) return
|
||||
const list = kind === 'log' ? logCats.value : kind === 'expense' ? expenseCats.value : incomeCats.value
|
||||
const clean = name.trim()
|
||||
if (list.includes(clean)) { showToast('分类已存在'); return }
|
||||
const next = [...list, clean]
|
||||
if (kind === 'log') { logCats.value = next; await setLogCategories(next) }
|
||||
else if (kind === 'expense') { expenseCats.value = next; await setLedgerCategories('expense', next) }
|
||||
else { incomeCats.value = next; await setLedgerCategories('income', next) }
|
||||
showToast('分类已添加')
|
||||
}
|
||||
async function renameCat(kind: 'log' | 'expense' | 'income', index: number, oldName: string) {
|
||||
const name = window.prompt('修改分类名称', oldName)
|
||||
if (!name || !name.trim() || name.trim() === oldName) return
|
||||
const list = kind === 'log' ? logCats.value : kind === 'expense' ? expenseCats.value : incomeCats.value
|
||||
const next = list.map((x, i) => (i === index ? name.trim() : x))
|
||||
if (kind === 'log') { logCats.value = next; await setLogCategories(next) }
|
||||
else if (kind === 'expense') { expenseCats.value = next; await setLedgerCategories('expense', next) }
|
||||
else { incomeCats.value = next; await setLedgerCategories('income', next) }
|
||||
showToast('分类已更新(历史记录自动跟随显示)')
|
||||
}
|
||||
async function removeCat(kind: 'log' | 'expense' | 'income', index: number) {
|
||||
const list = kind === 'log' ? logCats.value : kind === 'expense' ? expenseCats.value : incomeCats.value
|
||||
if (list.length <= 1) { showToast('至少保留一个分类'); return }
|
||||
if (!window.confirm('删除分类「' + list[index] + '」?历史记录不受影响。')) return
|
||||
const next = list.filter((_, i) => i !== index)
|
||||
if (kind === 'log') { logCats.value = next; await setLogCategories(next) }
|
||||
else if (kind === 'expense') { expenseCats.value = next; await setLedgerCategories('expense', next) }
|
||||
else { incomeCats.value = next; await setLedgerCategories('income', next) }
|
||||
showToast('分类已删除')
|
||||
}
|
||||
|
||||
// ---- 工作详情范围导出 ----
|
||||
const expRange = ref<'day' | 'week' | 'month' | 'custom'>('day')
|
||||
const expStart = ref(todayStr())
|
||||
const expEnd = ref(todayStr())
|
||||
function rangeStartEnd(): { start: string; end: string; label: string } {
|
||||
const t = todayStr()
|
||||
if (expRange.value === 'day') return { start: expStart.value, end: expStart.value, label: expStart.value }
|
||||
if (expRange.value === 'week') { const ws = weekStart(expStart.value); return { start: ws, end: addDays(ws, 6), label: ws + ' ~ ' + addDays(ws, 6) } }
|
||||
if (expRange.value === 'month') { const y = expStart.value.slice(0, 4); const m = expStart.value.slice(5, 7); const last = new Date(Number(y), Number(m), 0).getDate(); return { start: y + '-' + m + '-01', end: y + '-' + m + '-' + last, label: y + '-' + m } }
|
||||
return { start: expStart.value, end: expEnd.value, label: expStart.value + ' ~ ' + expEnd.value }
|
||||
}
|
||||
async function doExportRange() {
|
||||
const { start, end, label } = rangeStartEnd()
|
||||
const found = await Promise.all([listLogItemsBetween(start, end), listProjects(true)])
|
||||
const items = found[0]
|
||||
const projects = found[1]
|
||||
const pmap = new Map<number, string>(projects.map(p => [p.id as number, p.name]))
|
||||
const rows: (string | number | null)[][] = [['日期', '分类', '项目', '内容', '耗时(分钟)', '成果', '待跟进', '收获', '备注']]
|
||||
for (const it of items) {
|
||||
rows.push([
|
||||
it.date,
|
||||
categoryLabel(it.category),
|
||||
it.project_id ? (pmap.get(it.project_id) || '') : '',
|
||||
it.content,
|
||||
it.minutes == null ? '' : it.minutes,
|
||||
it.is_milestone ? '是' : '',
|
||||
it.needs_followup ? '是' : '',
|
||||
it.takeaway || '',
|
||||
it.remark || ''
|
||||
])
|
||||
}
|
||||
const file = '工作详情_' + label.replace(/[~s]/g, '_') + '.csv'
|
||||
const p = await downloadCsv(file, rows)
|
||||
showToast(p ? '已导出:' + p : '已导出到系统下载目录(' + items.length + ' 条)')
|
||||
}
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
const pendingImport = ref<{ name: string; text: string } | null>(null)
|
||||
const importError = ref('')
|
||||
@@ -109,6 +189,7 @@ async function load() {
|
||||
aiKey.value = await getSetting('ai_api_key', '')
|
||||
aiModel.value = await getSetting('ai_model', '')
|
||||
await loadSyncInfo()
|
||||
await loadCategories()
|
||||
}
|
||||
|
||||
async function saveAi() {
|
||||
@@ -375,6 +456,64 @@ async function doImport() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card section">
|
||||
<div class="section-title">工作分类</div>
|
||||
<div class="cat-block">
|
||||
<div class="cat-head"><span class="muted">工作记录分类</span><button class="btn btn-xs" @click="addCat('log')">添加</button></div>
|
||||
<div class="cat-chips">
|
||||
<span v-for="(c, i) in logCats" :key="c + i" class="cat-chip">
|
||||
{{ c }}
|
||||
<button class="chip-btn" title="修改" @click="renameCat('log', i, c)">改</button>
|
||||
<button class="chip-btn" title="删除" @click="removeCat('log', i)">删</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cat-block">
|
||||
<div class="cat-head"><span class="muted">记账支出分类</span><button class="btn btn-xs" @click="addCat('expense')">添加</button></div>
|
||||
<div class="cat-chips">
|
||||
<span v-for="(c, i) in expenseCats" :key="c + i" class="cat-chip">
|
||||
{{ c }}
|
||||
<button class="chip-btn" title="修改" @click="renameCat('expense', i, c)">改</button>
|
||||
<button class="chip-btn" title="删除" @click="removeCat('expense', i)">删</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cat-block">
|
||||
<div class="cat-head"><span class="muted">记账收入分类</span><button class="btn btn-xs" @click="addCat('income')">添加</button></div>
|
||||
<div class="cat-chips">
|
||||
<span v-for="(c, i) in incomeCats" :key="c + i" class="cat-chip">
|
||||
{{ c }}
|
||||
<button class="chip-btn" title="修改" @click="renameCat('income', i, c)">改</button>
|
||||
<button class="chip-btn" title="删除" @click="removeCat('income', i)">删</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card section">
|
||||
<div class="section-title">工作详情导出</div>
|
||||
<div class="seg">
|
||||
<button class="seg-btn" :class="{ active: expRange === 'day' }" @click="expRange = 'day'">当日</button>
|
||||
<button class="seg-btn" :class="{ active: expRange === 'week' }" @click="expRange = 'week'">当周</button>
|
||||
<button class="seg-btn" :class="{ active: expRange === 'month' }" @click="expRange = 'month'">当月</button>
|
||||
<button class="seg-btn" :class="{ active: expRange === 'custom' }" @click="expRange = 'custom'">自定义</button>
|
||||
</div>
|
||||
<div class="exp-dates">
|
||||
<template v-if="expRange === 'custom'">
|
||||
<input class="input" type="date" v-model="expStart" />
|
||||
<span class="muted">至</span>
|
||||
<input class="input" type="date" v-model="expEnd" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<input class="input" type="date" v-model="expStart" />
|
||||
<span class="muted loc-note">{{ expRange === 'day' ? '导出该日' : expRange === 'week' ? '导出该日期所在一周' : '导出该日期所在月份' }}</span>
|
||||
</template>
|
||||
</div>
|
||||
<div class="rule-save">
|
||||
<button class="btn btn-primary btn-sm" @click="doExportRange">导出 CSV</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card section">
|
||||
<div class="section-title">数据备份</div>
|
||||
<div class="backup-row">
|
||||
@@ -434,6 +573,21 @@ async function doImport() {
|
||||
.loc-note { font-size: 12px; }
|
||||
.ai-grid { display: flex; flex-direction: column; gap: 10px; }
|
||||
.backup-row { display: flex; gap: 8px; }
|
||||
.cat-block { margin-top: 10px; }
|
||||
.cat-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 6px; font-size: 12px; }
|
||||
.cat-chips { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.cat-chip {
|
||||
display: inline-flex; align-items: center; gap: 4px;
|
||||
border: 1px solid var(--border); border-radius: 999px;
|
||||
padding: 2px 8px; font-size: 12px;
|
||||
}
|
||||
.chip-btn { border: none; background: transparent; color: var(--text-3); cursor: pointer; font-size: 11px; padding: 0 1px; }
|
||||
.chip-btn:hover { color: var(--accent); }
|
||||
.exp-dates { display: flex; align-items: center; gap: 8px; margin-top: 10px; flex-wrap: wrap; }
|
||||
.exp-dates .input { width: 150px; }
|
||||
.seg { display: inline-flex; border: 1px solid var(--border-strong); border-radius: var(--radius-sm); overflow: hidden; }
|
||||
.seg-btn { border: none; background: var(--bg-page); color: var(--text-2); cursor: pointer; font-size: 12px; padding: 5px 12px; }
|
||||
.seg-btn.active { background: var(--accent-bg); color: var(--accent); }
|
||||
.import-error { color: var(--danger); font-size: 12px; margin-top: 8px; }
|
||||
.backup-note { font-size: 12px; margin-top: 10px; }
|
||||
</style>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
import { getWorkRule, listAttendanceBetween, listLogItemsBetween, listProjects } from '../db/repo'
|
||||
import { computeDay } from '../domain/attendance'
|
||||
import { CATEGORIES } from '../db/schema'
|
||||
import { getLogCategories } from '../db/categories'
|
||||
import { addDays, todayStr, weekStart } from '../lib/date'
|
||||
import { attendanceChanged } from '../lib/bus'
|
||||
|
||||
@@ -57,10 +57,15 @@ async function loadAll(): Promise<void> {
|
||||
.sort((a, b) => b.count - a.count)
|
||||
|
||||
const total = items.length || 1
|
||||
catStats.value = CATEGORIES
|
||||
.map(c => {
|
||||
const count = items.filter(i => i.category === c.key).length
|
||||
return { key: c.key, label: c.label, count, percent: Math.round((count / total) * 100) }
|
||||
const logCats = await getLogCategories()
|
||||
const catCount = new Map<string, number>()
|
||||
for (const it of items) catCount.set(it.category, (catCount.get(it.category) || 0) + 1)
|
||||
const catSet = new Set(logCats)
|
||||
for (const k of catCount.keys()) if (!catSet.has(k)) logCats.push(k)
|
||||
catStats.value = logCats
|
||||
.map(label => {
|
||||
const count = catCount.get(label) || 0
|
||||
return { key: label, label, count, percent: Math.round((count / total) * 100) }
|
||||
})
|
||||
.filter(c => c.count > 0)
|
||||
.sort((a, b) => b.count - a.count)
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ const PORT = Number(process.env.PORT || 3780)
|
||||
const TOKEN_TTL_MS = 30 * 24 * 60 * 60 * 1000 // 30 天
|
||||
const ITER = 150000
|
||||
|
||||
const TABLES = ['project', 'work_log', 'log_item', 'attendance', 'weekly_note', 'setting', 'memo', 'credential', 'transaction']
|
||||
const TABLES = ['project', 'work_log', 'log_item', 'attendance', 'weekly_note', 'setting', 'memo', 'credential', 'transaction', 'plan']
|
||||
|
||||
// 自然键:同一逻辑记录在两台设备上独立创建时的去重依据
|
||||
const NATURAL_KEYS = {
|
||||
|
||||
Reference in New Issue
Block a user