feat: 增加 i18n 能力(独立 vue-i18n 实例 + useLocale + 中英 locale)
- 新增 src/i18n/(独立实例,中文同步、英文懒加载)+ src/composables/useLocale.js - 抽取所有硬编码中文为 locale 文案(zh-CN/en) - 组件改用 useLocale 直读自身 i18n 实例(嵌入时不走 useI18n 注入) - App.vue 接 :locale prop,watch 同步父项目语言(嵌入 mainPage 时桥接) - 42 项测试全通过,构建正常
This commit is contained in:
68
package-lock.json
generated
68
package-lock.json
generated
@@ -9,7 +9,8 @@
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"ical.js": "^2.1.0",
|
||||
"vue": "^3.5.0"
|
||||
"vue": "^3.5.0",
|
||||
"vue-i18n": "^9.13.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.2.0",
|
||||
@@ -505,6 +506,47 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@intlify/core-base": {
|
||||
"version": "9.14.4",
|
||||
"resolved": "https://registry.npmmirror.com/@intlify/core-base/-/core-base-9.14.4.tgz",
|
||||
"integrity": "sha512-vtZCt7NqWhKEtHa3SD/322DlgP5uR9MqWxnE0y8Q0tjDs9H5Lxhss+b5wv8rmuXRoHKLESNgw9d+EN9ybBbj9g==",
|
||||
"dependencies": {
|
||||
"@intlify/message-compiler": "9.14.4",
|
||||
"@intlify/shared": "9.14.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/kazupon"
|
||||
}
|
||||
},
|
||||
"node_modules/@intlify/message-compiler": {
|
||||
"version": "9.14.4",
|
||||
"resolved": "https://registry.npmmirror.com/@intlify/message-compiler/-/message-compiler-9.14.4.tgz",
|
||||
"integrity": "sha512-vcyCLiVRN628U38c3PbahrhbbXrckrM9zpy0KZVlDk2Z0OnGwv8uQNNXP3twwGtfLsCf4gu3ci6FMIZnPaqZsw==",
|
||||
"dependencies": {
|
||||
"@intlify/shared": "9.14.4",
|
||||
"source-map-js": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/kazupon"
|
||||
}
|
||||
},
|
||||
"node_modules/@intlify/shared": {
|
||||
"version": "9.14.4",
|
||||
"resolved": "https://registry.npmmirror.com/@intlify/shared/-/shared-9.14.4.tgz",
|
||||
"integrity": "sha512-P9zv6i1WvMc9qDBWvIgKkymjY2ptIiQ065PjDv7z7fDqH3J/HBRBN5IoiR46r/ujRcU7hCuSIZWvCAFCyuOYZA==",
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/kazupon"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/sourcemap-codec": {
|
||||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||
@@ -1018,6 +1060,11 @@
|
||||
"@vue/shared": "3.5.39"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/devtools-api": {
|
||||
"version": "6.6.4",
|
||||
"resolved": "https://registry.npmmirror.com/@vue/devtools-api/-/devtools-api-6.6.4.tgz",
|
||||
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g=="
|
||||
},
|
||||
"node_modules/@vue/reactivity": {
|
||||
"version": "3.5.39",
|
||||
"resolved": "https://registry.npmmirror.com/@vue/reactivity/-/reactivity-3.5.39.tgz",
|
||||
@@ -2708,6 +2755,25 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vue-i18n": {
|
||||
"version": "9.14.4",
|
||||
"resolved": "https://registry.npmmirror.com/vue-i18n/-/vue-i18n-9.14.4.tgz",
|
||||
"integrity": "sha512-B934C8yUyWLT0EMud3DySrwSUJI7ZNiWYsEEz2gknTthqKiG4dzWE/WSa8AzCuSQzwBEv4HtG1jZDhgzPfWSKQ==",
|
||||
"dependencies": {
|
||||
"@intlify/core-base": "9.14.4",
|
||||
"@intlify/shared": "9.14.4",
|
||||
"@vue/devtools-api": "^6.5.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/kazupon"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/why-is-node-running": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmmirror.com/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"ical.js": "^2.1.0",
|
||||
"vue": "^3.5.0"
|
||||
"vue": "^3.5.0",
|
||||
"vue-i18n": "^9.13.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.2.0",
|
||||
|
||||
25
src/App.vue
25
src/App.vue
@@ -1,5 +1,8 @@
|
||||
<script setup>
|
||||
import { watch } from 'vue'
|
||||
import { useCalendar } from './composables/useCalendar.js'
|
||||
import { useLocale } from './composables/useLocale.js'
|
||||
import { loadLocaleAsync } from './i18n/index.js'
|
||||
import DropZone from './components/DropZone.vue'
|
||||
import HeaderBar from './components/HeaderBar.vue'
|
||||
import EventList from './components/EventList.vue'
|
||||
@@ -7,27 +10,41 @@ import EventDetail from './components/EventDetail.vue'
|
||||
import MonthPreview from './components/MonthPreview.vue'
|
||||
import WeekPreview from './components/WeekPreview.vue'
|
||||
|
||||
const props = defineProps({
|
||||
locale: { type: String, default: 'zh-CN' },
|
||||
})
|
||||
|
||||
const { isLoaded, state, setViewMode } = useCalendar()
|
||||
const { t } = useLocale()
|
||||
|
||||
// 跟随父项目语言开关切换本子项目 i18n 实例的 locale
|
||||
watch(
|
||||
() => props.locale,
|
||||
(val) => {
|
||||
if (val) loadLocaleAsync(val)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="ttf-app">
|
||||
<header class="ttf-header">
|
||||
<div class="ttf-header-left">
|
||||
<h1>ICS 日程整理器</h1>
|
||||
<h1>{{ t('app.title') }}</h1>
|
||||
<div v-if="isLoaded" class="ttf-view-switcher">
|
||||
<button
|
||||
:class="{ active: state.viewMode === 'edit' }"
|
||||
@click="setViewMode('edit')"
|
||||
>编辑</button>
|
||||
>{{ t('view.edit') }}</button>
|
||||
<button
|
||||
:class="{ active: state.viewMode === 'month' }"
|
||||
@click="setViewMode('month')"
|
||||
>月预览</button>
|
||||
>{{ t('view.month') }}</button>
|
||||
<button
|
||||
:class="{ active: state.viewMode === 'week' }"
|
||||
@click="setViewMode('week')"
|
||||
>周预览</button>
|
||||
>{{ t('view.week') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<HeaderBar />
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useCalendar } from '../composables/useCalendar.js'
|
||||
import { useLocale } from '../composables/useLocale.js'
|
||||
|
||||
const { loadFile } = useCalendar()
|
||||
const { t } = useLocale()
|
||||
const dragOver = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
@@ -12,11 +14,11 @@ function onDrop(e) {
|
||||
const file = e.dataTransfer.files[0]
|
||||
if (!file) return
|
||||
if (!file.name.toLowerCase().endsWith('.ics')) {
|
||||
error.value = '请选择 .ics 文件'
|
||||
error.value = t('dropzone.requireIcs')
|
||||
return
|
||||
}
|
||||
loadFile(file).catch((err) => {
|
||||
error.value = '解析失败:' + (err.message || err)
|
||||
error.value = t('dropzone.parseError') + (err.message || err)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -25,7 +27,7 @@ function onPick(e) {
|
||||
const file = e.target.files[0]
|
||||
if (!file) return
|
||||
loadFile(file).catch((err) => {
|
||||
error.value = '解析失败:' + (err.message || err)
|
||||
error.value = t('dropzone.parseError') + (err.message || err)
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@@ -39,9 +41,9 @@ function onPick(e) {
|
||||
@drop.prevent="onDrop"
|
||||
>
|
||||
<div class="dropzone-content">
|
||||
<p class="hint">拖入 .ics 文件,或</p>
|
||||
<p class="hint">{{ t('dropzone.hint') }}</p>
|
||||
<label class="pick-btn">
|
||||
点击选择文件
|
||||
{{ t('dropzone.pick') }}
|
||||
<input type="file" accept=".ics" @change="onPick" hidden />
|
||||
</label>
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
<script setup>
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import { useCalendar } from '../composables/useCalendar.js'
|
||||
import { useLocale } from '../composables/useLocale.js'
|
||||
import { DOW, DOW_EN, bydayFromDate } from '../lib/weekday.js'
|
||||
import { toDate } from '../lib/date.js'
|
||||
import OccurrenceGrid from './OccurrenceGrid.vue'
|
||||
|
||||
const { selectedEvent, updateEvent, deleteEvent, splitEvent, toggleOccurrence } = useCalendar()
|
||||
const { t } = useLocale()
|
||||
|
||||
// 本地表单状态, 选择切换时同步
|
||||
const form = ref({})
|
||||
@@ -70,7 +72,7 @@ watch(form, () => {
|
||||
function onDelete() {
|
||||
const ev = selectedEvent.value
|
||||
if (!ev) return
|
||||
if (confirm(`确定删除"${ev.summary}"?`)) {
|
||||
if (confirm(t('eventDetail.confirmDelete', { name: ev.summary }))) {
|
||||
deleteEvent(ev.uid)
|
||||
}
|
||||
}
|
||||
@@ -79,11 +81,11 @@ function onSplit() {
|
||||
const ev = selectedEvent.value
|
||||
if (!ev || !ev.rrule) return
|
||||
if (!splitDate.value) {
|
||||
alert('请选择拆分日期')
|
||||
alert(t('eventDetail.requireSplitDate'))
|
||||
return
|
||||
}
|
||||
if (splitDate.value <= ev.dtstartDate || splitDate.value > ev.rrule.untilDate) {
|
||||
alert('拆分日期必须在开始日期和结束日期之间')
|
||||
alert(t('eventDetail.splitDateInvalid'))
|
||||
return
|
||||
}
|
||||
splitEvent(ev.uid, splitDate.value)
|
||||
@@ -99,61 +101,61 @@ function onToggleOcc(date) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="!selectedEvent" class="empty">从左侧选择一个日程进行编辑</div>
|
||||
<div v-if="!selectedEvent" class="empty">{{ t('eventDetail.empty') }}</div>
|
||||
<div v-else class="detail">
|
||||
<!-- 基本信息 -->
|
||||
<div class="section-title">基本信息</div>
|
||||
<div class="section-title">{{ t('eventDetail.basicTitle') }}</div>
|
||||
<div class="form-group">
|
||||
<label>标题 (SUMMARY)</label>
|
||||
<label>{{ t('eventDetail.summary') }}</label>
|
||||
<input type="text" v-model="form.summary" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>开始时间</label>
|
||||
<label>{{ t('eventDetail.startTime') }}</label>
|
||||
<input type="time" v-model="form.dtstart_time" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>结束时间</label>
|
||||
<label>{{ t('eventDetail.endTime') }}</label>
|
||||
<input type="time" v-model="form.dtend_time" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>地点 (LOCATION)</label>
|
||||
<label>{{ t('eventDetail.location') }}</label>
|
||||
<input type="text" v-model="form.location" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>描述 (DESCRIPTION)</label>
|
||||
<label>{{ t('eventDetail.description') }}</label>
|
||||
<textarea v-model="form.description"></textarea>
|
||||
</div>
|
||||
|
||||
<!-- 重复规则 (RRULE) -->
|
||||
<div class="section-title">重复规则 (RRULE)</div>
|
||||
<div class="section-title">{{ t('eventDetail.rruleTitle') }}</div>
|
||||
<div class="form-group">
|
||||
<label>
|
||||
<input type="checkbox" v-model="form.is_recurring" /> 启用重复
|
||||
<input type="checkbox" v-model="form.is_recurring" /> {{ t('eventDetail.enableRecur') }}
|
||||
</label>
|
||||
</div>
|
||||
<div v-if="form.is_recurring">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>频率</label>
|
||||
<label>{{ t('eventDetail.freq') }}</label>
|
||||
<select v-model="form.freq">
|
||||
<option value="WEEKLY">每周 (WEEKLY)</option>
|
||||
<option value="DAILY">每天 (DAILY)</option>
|
||||
<option value="WEEKLY">{{ t('eventDetail.freqWeekly') }}</option>
|
||||
<option value="DAILY">{{ t('eventDetail.freqDaily') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>间隔 (INTERVAL)</label>
|
||||
<label>{{ t('eventDetail.interval') }}</label>
|
||||
<input type="number" v-model.number="form.interval" min="1" style="width: 80px" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>重复到 (UNTIL)</label>
|
||||
<label>{{ t('eventDetail.until') }}</label>
|
||||
<input type="date" v-model="form.until_date" />
|
||||
</div>
|
||||
<div class="form-group" v-if="form.freq === 'WEEKLY'">
|
||||
<label>星期 (BYDAY, 由开始日期自动确定)</label>
|
||||
<label>{{ t('eventDetail.byday') }}</label>
|
||||
<select v-model="form.byday" disabled>
|
||||
<option v-for="(d, i) in DOW_EN" :key="d" :value="d">
|
||||
{{ DOW[i] }}
|
||||
@@ -164,7 +166,7 @@ function onToggleOcc(date) {
|
||||
</div>
|
||||
|
||||
<!-- occurrence / 排除日期网格 -->
|
||||
<div v-if="form.is_recurring" class="section-title">occurrence / 排除日期</div>
|
||||
<div v-if="form.is_recurring" class="section-title">{{ t('eventDetail.occTitle') }}</div>
|
||||
<OccurrenceGrid
|
||||
v-if="form.is_recurring && selectedEvent.rrule"
|
||||
:event="selectedEvent"
|
||||
@@ -178,17 +180,17 @@ function onToggleOcc(date) {
|
||||
class="btn btn-secondary"
|
||||
@click="showSplit = !showSplit"
|
||||
>
|
||||
拆分此日程
|
||||
{{ t('eventDetail.split') }}
|
||||
</button>
|
||||
<button class="btn btn-danger" @click="onDelete">删除此日程</button>
|
||||
<button class="btn btn-danger" @click="onDelete">{{ t('eventDetail.delete') }}</button>
|
||||
</div>
|
||||
|
||||
<!-- 拆分面板 -->
|
||||
<div v-if="showSplit && selectedEvent.rrule" class="split-panel">
|
||||
<p>从指定日期起,把此重复事件拆成前后两段。</p>
|
||||
<p>{{ t('eventDetail.splitDesc') }}</p>
|
||||
<input type="date" v-model="splitDate" />
|
||||
<button class="btn btn-primary" @click="onSplit">确认拆分</button>
|
||||
<button class="btn btn-secondary" @click="showSplit = false">取消</button>
|
||||
<button class="btn btn-primary" @click="onSplit">{{ t('eventDetail.splitConfirm') }}</button>
|
||||
<button class="btn btn-secondary" @click="showSplit = false">{{ t('eventDetail.cancel') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
<script setup>
|
||||
import { useCalendar } from '../composables/useCalendar.js'
|
||||
import { useLocale } from '../composables/useLocale.js'
|
||||
import { DOW } from '../lib/weekday.js'
|
||||
import { toDate } from '../lib/date.js'
|
||||
|
||||
const { state, stats, addEvent, selectEvent } = useCalendar()
|
||||
const { t } = useLocale()
|
||||
|
||||
function dowFor(ev) {
|
||||
return DOW[toDate(ev.dtstartDate).getDay()]
|
||||
@@ -13,9 +15,9 @@ function dowFor(ev) {
|
||||
<template>
|
||||
<div class="event-list">
|
||||
<div class="stat">
|
||||
共 {{ stats.total }} 个日程({{ stats.recurring }} 重复 / {{ stats.single }} 单次)
|
||||
{{ t('eventList.stat', { total: stats.total, recurring: stats.recurring, single: stats.single }) }}
|
||||
</div>
|
||||
<button class="add-btn" @click="addEvent">新增日程</button>
|
||||
<button class="add-btn" @click="addEvent">{{ t('eventList.add') }}</button>
|
||||
<div class="list">
|
||||
<div
|
||||
v-for="ev in state.events"
|
||||
@@ -25,9 +27,9 @@ function dowFor(ev) {
|
||||
@click="selectEvent(ev.uid)"
|
||||
>
|
||||
<div class="title">
|
||||
{{ ev.summary || '(无标题)' }}
|
||||
{{ ev.summary || t('eventList.untitled') }}
|
||||
<span class="badge" :class="ev.rrule ? 'badge-recur' : 'badge-single'">
|
||||
{{ ev.rrule ? '重复' : '单次' }}
|
||||
{{ ev.rrule ? t('eventList.recurring') : t('eventList.single') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="meta">
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
<script setup>
|
||||
import { useCalendar } from '../composables/useCalendar.js'
|
||||
import { useLocale } from '../composables/useLocale.js'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const { state, organize, serialize, loadFile } = useCalendar()
|
||||
const { t } = useLocale()
|
||||
const fileInput = ref(null)
|
||||
|
||||
function onOrganize() {
|
||||
const stats = organize()
|
||||
if (stats && stats.series === 0) {
|
||||
alert('未发现可合并的重复模式')
|
||||
alert(t('header.noPattern'))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +21,7 @@ function onImport() {
|
||||
function onFilePicked(e) {
|
||||
const file = e.target.files[0]
|
||||
if (!file) return
|
||||
loadFile(file).catch((err) => alert('解析失败:' + (err.message || err)))
|
||||
loadFile(file).catch((err) => alert(t('header.parseError') + (err.message || err)))
|
||||
e.target.value = ''
|
||||
}
|
||||
|
||||
@@ -40,11 +42,11 @@ function onDownload() {
|
||||
<template>
|
||||
<div class="header-actions">
|
||||
<button class="btn btn-primary" @click="onOrganize" :disabled="state.events.length === 0">
|
||||
整理去重
|
||||
{{ t('header.organize') }}
|
||||
</button>
|
||||
<button class="btn btn-secondary" @click="onImport">导入文件</button>
|
||||
<button class="btn btn-secondary" @click="onImport">{{ t('header.import') }}</button>
|
||||
<button class="btn btn-success" @click="onDownload" :disabled="state.events.length === 0">
|
||||
下载 ICS
|
||||
{{ t('header.download') }}
|
||||
</button>
|
||||
<input ref="fileInput" type="file" accept=".ics" @change="onFilePicked" hidden />
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { useCalendar } from '../composables/useCalendar.js'
|
||||
import { useLocale } from '../composables/useLocale.js'
|
||||
import { expandOccurrences } from '../lib/recur.js'
|
||||
import { eventColor } from '../lib/colors.js'
|
||||
import {
|
||||
@@ -9,6 +10,7 @@ import {
|
||||
import { DOW } from '../lib/weekday.js'
|
||||
|
||||
const { state, prevMonth, nextMonth, goToToday } = useCalendar()
|
||||
const { t } = useLocale()
|
||||
|
||||
// 表头: 周一~周日
|
||||
const headers = DOW.slice(1).concat(DOW[0])
|
||||
@@ -19,7 +21,7 @@ const hoverEvent = ref(null)
|
||||
// 当前预览月的年月标签
|
||||
const monthLabel = computed(function () {
|
||||
const parts = parseISODate(startOfMonth(state.previewDate))
|
||||
return parts.year + ' 年 ' + parts.month + ' 月'
|
||||
return t('monthPreview.monthFmt', { y: parts.year, m: parts.month })
|
||||
})
|
||||
|
||||
// 构建月历网格 (6 行 x 7 列, 从当月首日所在周的周一开始)
|
||||
@@ -77,10 +79,10 @@ function hideTooltip() {
|
||||
<template>
|
||||
<div class="month-preview">
|
||||
<div class="preview-toolbar">
|
||||
<button class="nav-btn" @click="prevMonth">上个月</button>
|
||||
<button class="nav-btn" @click="prevMonth">{{ t('monthPreview.prev') }}</button>
|
||||
<span class="month-label">{{ monthLabel }}</span>
|
||||
<button class="nav-btn" @click="nextMonth">下个月</button>
|
||||
<button class="nav-btn today-btn" @click="goToToday">回到今日</button>
|
||||
<button class="nav-btn" @click="nextMonth">{{ t('monthPreview.next') }}</button>
|
||||
<button class="nav-btn today-btn" @click="goToToday">{{ t('monthPreview.today') }}</button>
|
||||
</div>
|
||||
<div class="month-grid">
|
||||
<div v-for="h in headers" :key="h" class="grid-header">{{ h }}</div>
|
||||
@@ -103,16 +105,16 @@ function hideTooltip() {
|
||||
@mouseenter="showTooltip(occ.event)"
|
||||
@mouseleave="hideTooltip"
|
||||
>
|
||||
{{ occ.event.summary || '(无标题)' }}
|
||||
{{ occ.event.summary || t('monthPreview.untitled') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 自定义 tooltip -->
|
||||
<div v-if="hoverEvent" class="event-tooltip" @mouseenter="showTooltip(hoverEvent)" @mouseleave="hideTooltip">
|
||||
<div class="tooltip-title">{{ hoverEvent.summary || '(无标题)' }}</div>
|
||||
<div class="tooltip-row">时间: {{ hoverEvent.dtstartTime }} - {{ hoverEvent.dtendTime }}</div>
|
||||
<div v-if="hoverEvent.location" class="tooltip-row">地点: {{ hoverEvent.location }}</div>
|
||||
<div class="tooltip-title">{{ hoverEvent.summary || t('monthPreview.untitled') }}</div>
|
||||
<div class="tooltip-row">{{ t('monthPreview.tooltipTime', { start: hoverEvent.dtstartTime, end: hoverEvent.dtendTime }) }}</div>
|
||||
<div v-if="hoverEvent.location" class="tooltip-row">{{ t('monthPreview.tooltipLocation', { location: hoverEvent.location }) }}</div>
|
||||
<div v-if="hoverEvent.description" class="tooltip-desc">{{ hoverEvent.description }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,11 +3,13 @@ import { computed } from 'vue'
|
||||
import { DOW } from '../lib/weekday.js'
|
||||
import { toDate } from '../lib/date.js'
|
||||
import { expandOccurrences } from '../lib/recur.js'
|
||||
import { useLocale } from '../composables/useLocale.js'
|
||||
|
||||
const props = defineProps({
|
||||
event: { type: Object, required: true },
|
||||
})
|
||||
const emit = defineEmits(['toggle'])
|
||||
const { t } = useLocale()
|
||||
|
||||
const occurrences = computed(() => expandOccurrences(props.event))
|
||||
|
||||
@@ -19,7 +21,7 @@ function dowFor(dateStr) {
|
||||
<template>
|
||||
<div>
|
||||
<p class="hint">
|
||||
点击日期切换"排除/包含"状态。排除的日期(红色删除线)会写入 EXDATE。
|
||||
{{ t('eventDetail.occHint') }}
|
||||
</p>
|
||||
<div class="occ-grid">
|
||||
<div
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useCalendar } from '../composables/useCalendar.js'
|
||||
import { useLocale } from '../composables/useLocale.js'
|
||||
import { expandOccurrences } from '../lib/recur.js'
|
||||
import { eventColor } from '../lib/colors.js'
|
||||
import {
|
||||
@@ -9,6 +10,7 @@ import {
|
||||
import { DOW } from '../lib/weekday.js'
|
||||
|
||||
const { state, prevWeek, nextWeek, goToToday } = useCalendar()
|
||||
const { t } = useLocale()
|
||||
|
||||
const HOUR_HEIGHT = 48 // 每小时像素高度
|
||||
const headers = DOW.slice(1).concat(DOW[0]) // 周一..周日
|
||||
@@ -159,10 +161,10 @@ function eventStyle(occ) {
|
||||
}
|
||||
|
||||
function tooltip(ev) {
|
||||
const parts = [ev.summary || '(无标题)']
|
||||
const parts = [ev.summary || t('weekPreview.untitled')]
|
||||
parts.push(ev.dtstartTime + ' - ' + ev.dtendTime)
|
||||
if (ev.location) parts.push('地点: ' + ev.location)
|
||||
if (ev.description) parts.push('描述: ' + ev.description)
|
||||
if (ev.location) parts.push(t('weekPreview.tooltipLocation', { location: ev.location }))
|
||||
if (ev.description) parts.push(t('weekPreview.tooltipDesc', { description: ev.description }))
|
||||
return parts.join('\n')
|
||||
}
|
||||
</script>
|
||||
@@ -170,14 +172,14 @@ function tooltip(ev) {
|
||||
<template>
|
||||
<div class="week-preview">
|
||||
<div class="preview-toolbar">
|
||||
<button class="nav-btn" @click="prevWeek">上一周</button>
|
||||
<button class="nav-btn" @click="prevWeek">{{ t('weekPreview.prev') }}</button>
|
||||
<span class="week-label">{{ weekLabel }}</span>
|
||||
<button class="nav-btn" @click="nextWeek">下一周</button>
|
||||
<button class="nav-btn today-btn" @click="goToToday">回到今日</button>
|
||||
<button class="nav-btn" @click="nextWeek">{{ t('weekPreview.next') }}</button>
|
||||
<button class="nav-btn today-btn" @click="goToToday">{{ t('weekPreview.today') }}</button>
|
||||
<button class="nav-btn collapse-btn" @click="toggleCollapse">
|
||||
{{ offHoursCollapsed ? '展开夜间' : '折叠夜间' }}
|
||||
{{ offHoursCollapsed ? t('weekPreview.expandNight') : t('weekPreview.collapseNight') }}
|
||||
</button>
|
||||
<span v-if="!hasEvents" class="empty-hint">本周无日程</span>
|
||||
<span v-if="!hasEvents" class="empty-hint">{{ t('weekPreview.noEvents') }}</span>
|
||||
</div>
|
||||
<div class="week-grid-wrapper">
|
||||
<div class="week-grid">
|
||||
@@ -215,7 +217,7 @@ function tooltip(ev) {
|
||||
:style="eventStyle(occ)"
|
||||
:title="tooltip(occ.event)"
|
||||
>
|
||||
<div class="event-title">{{ occ.event.summary || '(无标题)' }}</div>
|
||||
<div class="event-title">{{ occ.event.summary || t('weekPreview.untitled') }}</div>
|
||||
<div class="event-time">{{ occ.event.dtstartTime }} - {{ occ.event.dtendTime }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
14
src/composables/useLocale.js
Normal file
14
src/composables/useLocale.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import { computed } from 'vue'
|
||||
import i18n from '../i18n/index.js'
|
||||
|
||||
// ★ 子项目自有的语言辅助 composable。
|
||||
// 关键:直接读模块级 i18n 实例的 global composer,而非 useI18n() 注入。
|
||||
// 原因:useI18n({ useScope: 'global' }) 解析的是「当前运行 app 已安装的全局 i18n」,
|
||||
// 嵌入 mainPage 时那是父项目的实例(不含本子项目文案)-> 文案回退成 key。
|
||||
// 改为直接引用本子项目实例的 .global,无论是否 app.use() 都拿到本子项目的 t。
|
||||
export function useLocale() {
|
||||
const { t, locale } = i18n.global
|
||||
const isZh = computed(() => locale.value === 'zh-CN')
|
||||
const pick = (obj) => (isZh.value ? obj.zh : obj.en)
|
||||
return { t, locale, isZh, pick }
|
||||
}
|
||||
46
src/i18n/index.js
Normal file
46
src/i18n/index.js
Normal file
@@ -0,0 +1,46 @@
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import zhCN from './locales/zh-CN.js'
|
||||
|
||||
// ★ timeTableFix 独立 i18n 实例。中文同步注入(默认语言);英文动态 import() 懒加载。
|
||||
// 嵌入 mainPage 时,父项目把当前语言经 :locale prop 传入 App.vue,watch 同步到本实例,
|
||||
// 从而跟随父项目语言开关切换(不共享父的 messages,仅同步 locale 值,文案来自本子项目自有 locale)。
|
||||
// 注:组件内不用 useI18n() 注入(会解析到宿主实例),改用 ./composables/useLocale.js 直接读本实例。
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'zh-CN',
|
||||
fallbackLocale: 'zh-CN',
|
||||
messages: {
|
||||
'zh-CN': zhCN
|
||||
}
|
||||
})
|
||||
|
||||
const loadedLocales = new Set(['zh-CN'])
|
||||
|
||||
/**
|
||||
* 懒加载并切换到目标语言。首次切换某语言时动态 import 其语言包并 mergeLocaleMessage。
|
||||
* @param {string} locale
|
||||
*/
|
||||
export async function loadLocaleAsync(locale) {
|
||||
if (i18n.global.locale.value === locale) return
|
||||
if (loadedLocales.has(locale)) {
|
||||
i18n.global.locale.value = locale
|
||||
document.documentElement.setAttribute('lang', locale)
|
||||
return
|
||||
}
|
||||
|
||||
let messages
|
||||
if (locale === 'en') {
|
||||
messages = (await import('./locales/en.js')).default
|
||||
} else {
|
||||
i18n.global.locale.value = 'zh-CN'
|
||||
document.documentElement.setAttribute('lang', 'zh-CN')
|
||||
return
|
||||
}
|
||||
|
||||
i18n.global.mergeLocaleMessage(locale, messages)
|
||||
loadedLocales.add(locale)
|
||||
i18n.global.locale.value = locale
|
||||
document.documentElement.setAttribute('lang', locale)
|
||||
}
|
||||
|
||||
export default i18n
|
||||
78
src/i18n/locales/en.js
Normal file
78
src/i18n/locales/en.js
Normal file
@@ -0,0 +1,78 @@
|
||||
// 英文语言包 -- 通过动态 import() 加载,被打成独立 chunk。
|
||||
export default {
|
||||
app: {
|
||||
title: 'ICS Calendar Organizer'
|
||||
},
|
||||
view: {
|
||||
edit: 'Edit',
|
||||
month: 'Month',
|
||||
week: 'Week'
|
||||
},
|
||||
header: {
|
||||
organize: 'Organize & Dedupe',
|
||||
import: 'Import File',
|
||||
download: 'Download ICS',
|
||||
noPattern: 'No recurring patterns found to merge',
|
||||
parseError: 'Parse failed: '
|
||||
},
|
||||
dropzone: {
|
||||
hint: 'Drop an .ics file, or',
|
||||
pick: 'Click to select a file',
|
||||
requireIcs: 'Please select an .ics file',
|
||||
parseError: 'Parse failed: '
|
||||
},
|
||||
eventList: {
|
||||
stat: '{total} events ({recurring} recurring / {single} single)',
|
||||
add: 'Add Event',
|
||||
untitled: '(Untitled)',
|
||||
recurring: 'Recurring',
|
||||
single: 'Single'
|
||||
},
|
||||
eventDetail: {
|
||||
empty: 'Select an event from the left to edit',
|
||||
basicTitle: 'Basic Info',
|
||||
summary: 'Title (SUMMARY)',
|
||||
startTime: 'Start Time',
|
||||
endTime: 'End Time',
|
||||
location: 'Location (LOCATION)',
|
||||
description: 'Description (DESCRIPTION)',
|
||||
rruleTitle: 'Recurrence Rule (RRULE)',
|
||||
enableRecur: 'Enable recurrence',
|
||||
freq: 'Frequency',
|
||||
freqWeekly: 'Weekly (WEEKLY)',
|
||||
freqDaily: 'Daily (DAILY)',
|
||||
interval: 'Interval (INTERVAL)',
|
||||
until: 'Until (UNTIL)',
|
||||
byday: 'Weekday (BYDAY, auto from start date)',
|
||||
occTitle: 'occurrence / excluded dates',
|
||||
occHint: 'Click a date to toggle "exclude/include". Excluded dates (red strikethrough) are written to EXDATE.',
|
||||
split: 'Split this event',
|
||||
delete: 'Delete this event',
|
||||
splitDesc: 'Split this recurring event into two segments from the specified date.',
|
||||
splitConfirm: 'Confirm Split',
|
||||
cancel: 'Cancel',
|
||||
confirmDelete: 'Delete "{name}"?',
|
||||
requireSplitDate: 'Please select a split date',
|
||||
splitDateInvalid: 'Split date must be between the start and end dates'
|
||||
},
|
||||
monthPreview: {
|
||||
prev: 'Prev Month',
|
||||
next: 'Next Month',
|
||||
today: 'Today',
|
||||
untitled: '(Untitled)',
|
||||
monthFmt: '{y}/{m}',
|
||||
tooltipTime: 'Time: {start} - {end}',
|
||||
tooltipLocation: 'Location: {location}'
|
||||
},
|
||||
weekPreview: {
|
||||
prev: 'Prev Week',
|
||||
next: 'Next Week',
|
||||
today: 'Today',
|
||||
expandNight: 'Show Night',
|
||||
collapseNight: 'Hide Night',
|
||||
noEvents: 'No events this week',
|
||||
untitled: '(Untitled)',
|
||||
tooltipLocation: 'Location: {location}',
|
||||
tooltipDesc: 'Description: {description}'
|
||||
}
|
||||
}
|
||||
78
src/i18n/locales/zh-CN.js
Normal file
78
src/i18n/locales/zh-CN.js
Normal file
@@ -0,0 +1,78 @@
|
||||
// 中文语言包(默认语言,同步注入主 chunk)
|
||||
export default {
|
||||
app: {
|
||||
title: 'ICS 日程整理器'
|
||||
},
|
||||
view: {
|
||||
edit: '编辑',
|
||||
month: '月预览',
|
||||
week: '周预览'
|
||||
},
|
||||
header: {
|
||||
organize: '整理去重',
|
||||
import: '导入文件',
|
||||
download: '下载 ICS',
|
||||
noPattern: '未发现可合并的重复模式',
|
||||
parseError: '解析失败:'
|
||||
},
|
||||
dropzone: {
|
||||
hint: '拖入 .ics 文件,或',
|
||||
pick: '点击选择文件',
|
||||
requireIcs: '请选择 .ics 文件',
|
||||
parseError: '解析失败:'
|
||||
},
|
||||
eventList: {
|
||||
stat: '共 {total} 个日程({recurring} 重复 / {single} 单次)',
|
||||
add: '新增日程',
|
||||
untitled: '(无标题)',
|
||||
recurring: '重复',
|
||||
single: '单次'
|
||||
},
|
||||
eventDetail: {
|
||||
empty: '从左侧选择一个日程进行编辑',
|
||||
basicTitle: '基本信息',
|
||||
summary: '标题 (SUMMARY)',
|
||||
startTime: '开始时间',
|
||||
endTime: '结束时间',
|
||||
location: '地点 (LOCATION)',
|
||||
description: '描述 (DESCRIPTION)',
|
||||
rruleTitle: '重复规则 (RRULE)',
|
||||
enableRecur: '启用重复',
|
||||
freq: '频率',
|
||||
freqWeekly: '每周 (WEEKLY)',
|
||||
freqDaily: '每天 (DAILY)',
|
||||
interval: '间隔 (INTERVAL)',
|
||||
until: '重复到 (UNTIL)',
|
||||
byday: '星期 (BYDAY, 由开始日期自动确定)',
|
||||
occTitle: 'occurrence / 排除日期',
|
||||
occHint: '点击日期切换"排除/包含"状态。排除的日期(红色删除线)会写入 EXDATE。',
|
||||
split: '拆分此日程',
|
||||
delete: '删除此日程',
|
||||
splitDesc: '从指定日期起,把此重复事件拆成前后两段。',
|
||||
splitConfirm: '确认拆分',
|
||||
cancel: '取消',
|
||||
confirmDelete: '确定删除"{name}"?',
|
||||
requireSplitDate: '请选择拆分日期',
|
||||
splitDateInvalid: '拆分日期必须在开始日期和结束日期之间'
|
||||
},
|
||||
monthPreview: {
|
||||
prev: '上个月',
|
||||
next: '下个月',
|
||||
today: '回到今日',
|
||||
untitled: '(无标题)',
|
||||
monthFmt: '{y} 年 {m} 月',
|
||||
tooltipTime: '时间: {start} - {end}',
|
||||
tooltipLocation: '地点: {location}'
|
||||
},
|
||||
weekPreview: {
|
||||
prev: '上一周',
|
||||
next: '下一周',
|
||||
today: '回到今日',
|
||||
expandNight: '展开夜间',
|
||||
collapseNight: '折叠夜间',
|
||||
noEvents: '本周无日程',
|
||||
untitled: '(无标题)',
|
||||
tooltipLocation: '地点: {location}',
|
||||
tooltipDesc: '描述: {description}'
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import i18n from './i18n/index.js'
|
||||
import './styles/global.css'
|
||||
|
||||
createApp(App).mount('#app')
|
||||
createApp(App).use(i18n).mount('#app')
|
||||
|
||||
Reference in New Issue
Block a user