Files
yangxiangyuan d3856cb52a chore: add and update gitignore rules for project files
add a comprehensive gitignore file to exclude unnecessary files like dependencies, runtime data, uploads, local configs and debug temporary files
2026-06-22 21:24:46 +08:00

1603 lines
63 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const state = {
dashboard: null,
plants: [],
selectedPlantId: 0,
weather: null,
logs: [],
photos: [],
moodTags: [],
settings: null,
notificationGranted: typeof Notification !== 'undefined' && Notification.permission === 'granted',
notificationTimer: null,
interactionContext: null,
interactionTimer: null,
confirmResolver: null,
uiMode: 'debug',
activeReminderRule: null,
buttonStates: {} // Track button loading states
}
const getUiMode = () => state.uiMode
// 按钮状态管理函数
const setButtonLoading = (button, isLoading, originalText = null) => {
if (!button) return
if (isLoading) {
// 保存原始状态
if (!originalText) {
originalText = button.textContent || button.innerText
}
state.buttonStates[button.id || button.name || button.classList?.value || 'temp'] = {
originalText,
originalDisabled: button.disabled
}
// 设置加载状态
button.disabled = true
button.textContent = button.textContent.replace(/…$/, '') + '…'
} else {
// 恢复原始状态
const key = button.id || button.name || button.classList?.value || 'temp'
const originalState = state.buttonStates[key]
if (originalState) {
button.disabled = originalState.originalDisabled
button.textContent = originalState.originalText
delete state.buttonStates[key]
}
}
}
// 包装异步操作的函数,自动处理按钮loading状态
const withButtonLoading = async (button, asyncFn) => {
if (!button) return await asyncFn()
setButtonLoading(button, true)
try {
return await asyncFn()
} finally {
setButtonLoading(button, false)
}
}
const $ = selector => document.querySelector(selector)
const $$ = selector => Array.from(document.querySelectorAll(selector))
const escapeHtml = value => String(value == null ? '' : value)
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
const todayStr = () => {
const d = new Date()
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}
const openDialog = id => {
const dlg = document.getElementById(id)
if (!dlg) return
if (id === 'interaction-dialog') {
if (typeof dlg.showModal === 'function' && !dlg.open) dlg.showModal()
return
}
if (typeof dlg.showModal === 'function' && !dlg.open) dlg.showModal()
}
const closeDialog = id => {
const dlg = document.getElementById(id)
if (id === 'interaction-dialog') {
clearInteractionTimer()
if (dlg && typeof dlg.close === 'function') dlg.close()
return
}
if (dlg && typeof dlg.close === 'function') dlg.close()
}
const clearInteractionTimer = () => {
if (state.interactionTimer) {
window.clearInterval(state.interactionTimer)
state.interactionTimer = null
}
}
const openToast = (message, type = 'info', duration = 2000) => {
if (getUiMode() !== 'normal') return
const id = `toast-${Date.now()}`
const toast = document.createElement('div')
toast.className = `toast toast-${type}`
toast.id = id
toast.textContent = message
document.body.appendChild(toast)
window.requestAnimationFrame(() => toast.classList.add('active'))
window.setTimeout(() => {
if (toast.classList.contains('active')) {
toast.classList.remove('active')
window.setTimeout(() => toast.remove(), 300)
}
}, duration)
}
const formatTime = input => {
try {
return new Date(input || Date.now()).toLocaleString('zh-CN', { hour12: false })
} catch {
return String(input || '')
}
}
const getFriendlyErrorMessage = error => {
const raw = String((error && (error.message || error.error)) || error || '').trim()
if (raw === 'dashscope_api_key_missing') {
return '未配置 DashScope API Key,请先在 `config/plant_home.json` 的 `ai.api_key` 中填入有效密钥。'
}
if (raw === 'qweather_api_key_missing') {
return '未配置和风天气 API Key,请先在 `config/plant_home.json` 的 `weather.api_key` 中填入有效密钥。'
}
if (raw === 'plant_name_required') {
return '请先填写植物名称,再点“辅助填写”。'
}
if (raw === 'home_feature_limit_reached') {
return '这棵植物最多只能设置 3 张封面图,请先取消一张现有封面。'
}
if (raw === 'active_rule_exists') {
return '当前植物的同类提醒已经存在,请先确认是否覆盖旧提醒。'
}
if (raw === 'weekly_days_required') {
return '每周循环至少要选择一个周几。'
}
if (raw === 'tag_name_required') {
return '请先输入标签名称。'
}
if (raw === 'log_not_found') {
return '这条日志已经不存在了,可能是旧页面残留数据。'
}
return raw || '发生未知错误'
}
const normalizeFormData = formData => {
const result = {}
for (const [key, value] of formData.entries()) {
const nextValue = value instanceof File
? { name: value.name, size: value.size, type: value.type }
: value
if (Object.prototype.hasOwnProperty.call(result, key)) {
if (!Array.isArray(result[key])) result[key] = [result[key]]
result[key].push(nextValue)
} else {
result[key] = nextValue
}
}
return result
}
const toPrettyText = value => {
if (value == null || value === '') return '(空)'
if (value instanceof FormData) return JSON.stringify(normalizeFormData(value), null, 2)
if (typeof value === 'string') {
try {
return JSON.stringify(JSON.parse(value), null, 2)
} catch {
return value
}
}
try {
return JSON.stringify(value, null, 2)
} catch {
return String(value)
}
}
const toArray = value => Array.isArray(value) ? value : (value == null ? [] : [value])
const summarizeRuleText = rule => {
if (!rule) return '当前没有同类提醒'
return `当前已有提醒:${rule.title || ''} / ${rule.cycle_text || ''} / 下次 ${rule.next_due_date || ''} ${rule.due_time || ''}`.trim()
}
const getWeekdayValuesFromForm = form => {
return $$('#log-weekday-list input[type="checkbox"]:checked').map(input => Number(input.value)).filter(Boolean)
}
const syncWeeklyDaysField = () => {
const form = $('#log-form')
if (!form) return
form.reminder_weekly_days_json.value = JSON.stringify(getWeekdayValuesFromForm(form))
$$('#log-weekday-list .tag-chip-option').forEach(label => {
const input = label.querySelector('input')
label.classList.toggle('active', !!(input && input.checked))
})
}
const renderSingleMoodTagPicker = (containerSelector, inputSelector) => {
const box = $(containerSelector)
if (!box) return
const currentValue = document.querySelector(inputSelector)?.value || ''
box.innerHTML = state.moodTags.map(tag => `
<button type="button" class="tag-chip-btn ${currentValue === tag.name ? 'active' : ''}" data-action="pick-mood-tag" data-target-input-selector="${escapeHtml(inputSelector)}" data-tag-value="${escapeHtml(tag.name)}">${escapeHtml(tag.name)}</button>
`).join('')
}
const renderMoodTagPickers = () => {
renderSingleMoodTagPicker('#log-mood-tag-list', '#log-form [name="mood_tag"]')
renderSingleMoodTagPicker('#reminder-mood-tag-list', '#reminder-form [name="mood_tag"]')
}
const updateReminderModeFields = () => {
const mode = $('#log-reminder-mode')?.value || 'once'
$('#log-reminder-once')?.classList.toggle('hidden', mode !== 'once')
$('#log-reminder-every-n')?.classList.toggle('hidden', mode !== 'every_n_days')
$('#log-reminder-weekly')?.classList.toggle('hidden', mode !== 'weekly')
$('#log-reminder-monthly')?.classList.toggle('hidden', mode !== 'monthly')
}
const updateReminderEnabledFields = () => {
const enabled = !!$('#log-reminder-enabled')?.checked
$('#log-reminder-fields')?.classList.toggle('hidden', !enabled)
}
const applyMoodTagToForm = (inputSelector, tagValue) => {
const input = document.querySelector(inputSelector)
if (!input) return
input.value = input.value === tagValue ? '' : tagValue
renderMoodTagPickers()
}
const appendInteractionLine = (session, label, value) => {
if (!session) return
const chunk = value === undefined
? String(label || '')
: `${label}\n${toPrettyText(value)}`
session.logs.push(chunk)
const output = $('#interaction-output')
if (output) {
output.textContent = session.logs.join('\n\n')
output.scrollTop = output.scrollHeight
}
}
const setInteractionStatus = (text, type = '') => {
const box = $('#interaction-status')
if (!box) return
box.textContent = text || ''
box.className = `interaction-status${type ? ` ${type}` : ''}`
}
const openInteractionSession = ({ title, mode = 'normal' }) => {
clearInteractionTimer()
const session = {
id: `interaction-${Date.now()}`,
title: title || '本次交互详情',
mode,
startedAt: Date.now(),
logs: []
}
$('#interaction-title').textContent = session.title
$('#interaction-subtitle').textContent = `开始时间:${formatTime(session.startedAt)}`
$('#interaction-output').textContent = ''
setInteractionStatus('进行中,正在记录详细交互内容...', '')
openDialog('interaction-dialog')
appendInteractionLine(session, '[交互开始]', {
title: session.title,
mode: session.mode,
started_at: formatTime(session.startedAt)
})
return session
}
const copyInteractionLog = async () => {
const session = state.interactionContext
const text = session ? session.logs.join('\n\n') : $('#interaction-output').textContent
if (!navigator.clipboard || !navigator.clipboard.writeText) {
throw new Error('clipboard_not_supported')
}
await navigator.clipboard.writeText(text || '')
}
const finishInteractionSuccess = session => {
if (!session) return
const elapsed = Date.now() - session.startedAt
appendInteractionLine(session, '[交互完成]', {
finished_at: formatTime(),
elapsed_ms: elapsed
})
if (session.mode === 'view') {
let remain = 3
if (getUiMode() === 'normal') remain = 2
setInteractionStatus(`查看成功,${remain}s 后自动关闭`, 'success')
clearInteractionTimer()
state.interactionTimer = window.setInterval(() => {
remain -= 1
if (remain <= 0) {
clearInteractionTimer()
closeDialog('interaction-dialog')
return
}
setInteractionStatus(`查看成功,${remain}s 后自动关闭`, 'success')
}, 1000)
return
}
setInteractionStatus('执行成功,请确认明细后手动关闭窗口', 'success')
}
const finishInteractionError = (session, error) => {
if (!session) return
const elapsed = Date.now() - session.startedAt
const friendlyMessage = getFriendlyErrorMessage(error)
appendInteractionLine(session, '[交互失败]', {
failed_at: formatTime(),
elapsed_ms: elapsed,
message: error && error.message ? error.message : String(error || 'unknown_error'),
friendly_message: friendlyMessage,
stack: error && error.stack ? String(error.stack) : ''
})
setInteractionStatus(`出现异常:${friendlyMessage}`, 'error')
}
const runTrackedInteraction = async ({ title, mode = 'normal' }, executor) => {
if (state.interactionContext) {
appendInteractionLine(state.interactionContext, '[子任务开始]', { title, mode, at: formatTime() })
try {
const result = await executor(state.interactionContext)
appendInteractionLine(state.interactionContext, '[子任务完成]', { title, at: formatTime() })
return result
} catch (error) {
appendInteractionLine(state.interactionContext, '[子任务异常]', {
title,
at: formatTime(),
message: error && error.message ? error.message : String(error || 'unknown_error')
})
throw error
}
}
if (getUiMode() === 'normal' && mode === 'view') {
openToast(`${title}完成`, 'success', 2000)
return await executor(null)
}
const session = openInteractionSession({ title, mode })
state.interactionContext = session
try {
const result = await executor(session)
finishInteractionSuccess(session)
return result
} catch (error) {
finishInteractionError(session, error)
throw error
} finally {
if (state.interactionContext && state.interactionContext.id === session.id) {
state.interactionContext = null
}
}
}
const api = async (url, options = {}) => {
const method = String(options.method || 'GET').toUpperCase()
const requestHeaders = {
...(options.body instanceof FormData ? {} : { 'Content-Type': 'application/json' }),
...(options.headers || {})
}
const session = state.interactionContext
const startedAt = Date.now()
if (session) {
appendInteractionLine(session, '[请求开始]', {
url,
method,
started_at: formatTime(startedAt),
headers: requestHeaders,
body: options.body instanceof FormData ? normalizeFormData(options.body) : (options.body || '(空)')
})
}
let resp = null
let rawText = ''
try {
resp = await fetch(url, {
headers: requestHeaders,
...options
})
rawText = await resp.text()
} catch (error) {
if (session) {
appendInteractionLine(session, '[网络异常]', {
url,
method,
elapsed_ms: Date.now() - startedAt,
message: error && error.message ? error.message : String(error || 'network_error')
})
}
throw error
}
let data = {}
try {
data = rawText ? JSON.parse(rawText) : {}
} catch {
data = { raw_text: rawText }
}
if (session) {
appendInteractionLine(session, '[请求返回]', {
url,
method,
status: resp.status,
ok: resp.ok,
elapsed_ms: Date.now() - startedAt,
response: data
})
}
if (!resp.ok || data.ok === false) {
const error = new Error((data && data.error) || `request_failed_${resp.status}`)
error.status = resp.status
error.response = data
throw error
}
return data
}
const openConfirmDialog = ({ title, message, detail = '', confirmText = '确认' }) => new Promise(resolve => {
state.confirmResolver = resolve
$('#confirm-title').textContent = title || '请确认'
$('#confirm-message').textContent = message || '确认继续吗?'
$('#confirm-detail').textContent = detail || ''
$('#btn-ok-confirm').textContent = confirmText || '确认'
openDialog('confirm-dialog')
})
const resolveConfirmDialog = value => {
const resolver = state.confirmResolver
state.confirmResolver = null
closeDialog('confirm-dialog')
if (resolver) resolver(!!value)
}
const openDeleteConfirmDialog = ({ title, message, detail }) => openConfirmDialog({
title,
message,
detail: toPrettyText({
...(detail || {}),
warning: '该操作不可恢复'
}),
confirmText: '确认删除'
})
const showAnalysis = (text, { inline = false } = {}) => {
if (getUiMode() === 'normal' && inline) {
openToast(`AI 分析结果已内联显示`, 'info', 2000)
return
}
$('#analysis-output').textContent = text || '暂无结果'
openDialog('analysis-dialog')
}
const setFieldValue = (form, name, value, fallback = '') => {
if (!form || !form[name]) return
form[name].value = value == null || value === '' ? fallback : value
}
const applyPlantDraftToForm = draft => {
const form = $('#plant-form')
const next = draft || {}
const rules = next.rules || {}
const adjust = rules.weather_adjust || {}
setFieldValue(form, 'name', next.name, form.name.value || '')
setFieldValue(form, 'alias', next.alias)
setFieldValue(form, 'scientific_name', next.scientific_name)
setFieldValue(form, 'room_area', next.room_area)
setFieldValue(form, 'light_env', next.light_env)
setFieldValue(form, 'plant_group', next.plant_group)
setFieldValue(form, 'status_label', next.status_label, '状态良好')
setFieldValue(form, 'light_text', next.light_text)
setFieldValue(form, 'watering_text', next.watering_text)
setFieldValue(form, 'temp_text', next.temp_text)
setFieldValue(form, 'ventilation_text', next.ventilation_text)
setFieldValue(form, 'grow_light_text', next.grow_light_text)
setFieldValue(form, 'fertilizing_text', next.fertilizing_text)
setFieldValue(form, 'soil_text', next.soil_text)
setFieldValue(form, 'ai_summary', next.ai_summary)
setFieldValue(form, 'watering_interval_days', rules.watering_interval_days, '7')
setFieldValue(form, 'fertilizing_interval_days', rules.fertilizing_interval_days, '30')
setFieldValue(form, 'pruning_interval_days', rules.pruning_interval_days, '90')
setFieldValue(form, 'repot_interval_days', rules.repot_interval_days, '180')
setFieldValue(form, 'watering_hot_delta', adjust.watering_hot_delta, '-1')
setFieldValue(form, 'watering_humid_delta', adjust.watering_humid_delta, '1')
setFieldValue(form, 'watering_cold_delta', adjust.watering_cold_delta, '2')
}
const assistPlantDraft = async () => {
const form = $('#plant-form')
const inputName = String(form.name.value || '').trim()
if (!inputName) {
await runTrackedInteraction({ title: '辅助填写植物档案', mode: 'normal' }, async () => {
throw new Error('请先输入植物名称')
})
return
}
await runTrackedInteraction({ title: 'AI 辅助填写植物档案', mode: 'normal' }, async session => {
const data = await api('/api/plant_home/plants/assist_draft', {
method: 'POST',
body: JSON.stringify({ name: inputName, chosen_name: '' })
})
applyPlantDraftToForm(data.draft || {})
if (data.chosen_name) form.name.value = data.chosen_name
appendInteractionLine(session, '[表单回填完成]', data.draft || {})
})
}
const renderSummary = stats => {
const box = $('#summary-grid')
if (!box) return
const cards = [
['植物数', stats && stats.plant_count || 0],
['待处理提醒', stats && stats.due_today_count || 0],
['养护日志', stats && stats.log_count || 0],
['相册图片', stats && stats.photo_count || 0]
]
box.innerHTML = cards.map(([label, value]) => `
<div class="summary-card">
<div class="summary-label">${label}</div>
<div class="summary-value">${value}</div>
</div>
`).join('')
}
const renderWeather = weather => {
const bar = $('#weather-bar')
if (!bar) return
if (!weather) {
bar.textContent = '天气暂无数据'
return
}
// 根据icon字段值映射天气图标
const getWeatherIcon = (iconCode) => {
const code = parseInt(iconCode || '999', 10)
if (code === 100) return '☀️' // 晴
if (code >= 101 && code <= 104) return '🌤️' // 多云
if (code >= 300 && code <= 399) return '🌧️' // 雨
if (code >= 400) return '❄️' // 雪
return '🌈' // 兜底
}
const location = weather.location || {}
const cityName = escapeHtml(location.display_name || '上海宝山区')
const floorEnv = escapeHtml(weather.floor_env || '17楼室内') // 假设从配置中获取楼层环境
let temp = escapeHtml(weather.temp || '--')
let humidity = escapeHtml(weather.humidity || '--')
const windDir = escapeHtml(weather.windDir || '')
const windScale = escapeHtml(weather.windScale || '')
const weatherText = escapeHtml(weather.text || '')
const adviceText = escapeHtml(weather.advice_text || '暂无建议')
const weatherIcon = getWeatherIcon(weather.icon)
// 转换 data_gateway 更新时间为北京时间
const formatBeijingTime = (utcStr) => {
if (!utcStr) return ''
const d = new Date(utcStr.replace(' ', 'T') + '+00:00')
if (isNaN(d.getTime())) return ''
const pad = n => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${pad(d.getMilliseconds())}`
}
const dataTimestamp = formatBeijingTime(weather._data_gateway_updated_at)
const dataSourceLabel = dataTimestamp ? `数据更新:${dataTimestamp}` : ''
// 检查温度是否已经包含°C,如果没有则添加
if (temp && !temp.includes('°C')) {
temp = temp + '°C'
}
// 检查湿度是否已经包含%,如果没有则添加
if (humidity && !humidity.includes('%')) {
humidity = humidity + '%'
}
bar.innerHTML = `
<div class="weather-location">
<span class="location-icon">📍</span>
<div class="location-city">${cityName}</div>
<div class="location-env">${floorEnv}</div>
</div>
<div class="weather-main">
<div class="weather-temp">${temp}</div>
<div class="weather-text">${weatherText}</div>
<div class="weather-icon">${weatherIcon}</div>
<div class="weather-details">
<div class="detail-item">
<span class="detail-label">湿度</span>
<span class="detail-value">${humidity}</span>
</div>
<div class="detail-item">
<span class="detail-label">风力</span>
<span class="detail-value">${windDir}${windScale}级</span>
</div>
</div>
</div>
<div class="weather-advice">
<div class="advice-text" id="advice-text">${adviceText}</div>
${dataSourceLabel ? `<div class="weather-data-timestamp">${escapeHtml(dataSourceLabel)}</div>` : ''}
<button class="expand-btn" id="expand-advice-btn">展开</button>
</div>
`
// 绑定展开按钮事件
const expandBtn = $('#expand-advice-btn')
const adviceTextElement = $('#advice-text')
if (expandBtn && adviceTextElement) {
expandBtn.onclick = () => {
// 创建弹出层显示完整内容
const popup = document.createElement('div')
popup.className = 'advice-popup'
popup.innerHTML = `<div>${adviceText}</div>`
// 定位弹出层
const rect = adviceTextElement.getBoundingClientRect()
popup.style.position = 'absolute'
popup.style.left = rect.left + 'px'
popup.style.top = (rect.bottom + 10) + 'px'
document.body.appendChild(popup)
// 点击其他地方关闭弹窗
const closePopup = (e) => {
if (!popup.contains(e.target)) {
document.body.removeChild(popup)
document.removeEventListener('click', closePopup)
}
}
document.addEventListener('click', closePopup)
}
}
}
const initPlantCardRotators = () => {
$$('.plant-cover-rotator').forEach(rotator => {
if (rotator.dataset.bound === '1') return
rotator.dataset.bound = '1'
const slides = Array.from(rotator.querySelectorAll('.plant-cover-slide'))
const dots = Array.from(rotator.querySelectorAll('.plant-cover-dot'))
if (slides.length <= 1) return
let current = 0
const activate = index => {
current = index
slides.forEach((slide, idx) => slide.classList.toggle('active', idx === index))
dots.forEach((dot, idx) => dot.classList.toggle('active', idx === index))
}
rotator.dataset.timerId = String(window.setInterval(() => {
activate((current + 1) % slides.length)
}, 3200))
})
}
const buildPlantCard = (plant, active = false) => {
const nextReminder = plant.next_reminder
const covers = Array.isArray(plant.card_covers) ? plant.card_covers.slice(0, 3) : []
const displayAvatar = plant.display_avatar_path || plant.avatar_path || ''
const thumb = covers.length > 0
? `
<div class="plant-cover-rotator" data-cover-count="${covers.length}">
${covers.map((item, index) => `
<img class="plant-cover-slide ${index === 0 ? 'active' : ''}" src="${escapeHtml(item.file_path)}" alt="${escapeHtml(plant.name)}" data-cover-index="${index}">
`).join('')}
${covers.length > 1 ? `<div class="plant-cover-dots">${covers.map((_, index) => `<span class="plant-cover-dot ${index === 0 ? 'active' : ''}" data-cover-dot="${index}"></span>`).join('')}</div>` : ''}
</div>
`
: displayAvatar
? `<img src="${escapeHtml(displayAvatar)}" alt="${escapeHtml(plant.name)}">`
: '🪴'
return `
<article class="plant-card ${active ? 'active' : ''}" data-plant-id="${plant.id}">
<div class="plant-thumb">${thumb}</div>
<div class="plant-name">${escapeHtml(plant.name)}</div>
<div class="plant-meta">
<div>${escapeHtml(plant.room_area || plant.light_env || '未设置位置')}</div>
<div><span class="status-chip">${escapeHtml(plant.status_label || '状态良好')}</span></div>
${covers.length > 0 ? `<div>封面图:${covers.length} 张${covers.length > 1 ? ',卡片内轮播' : ''}</div>` : ''}
${nextReminder ? `<div>下次提醒:${escapeHtml(nextReminder.due_date)} · ${escapeHtml(nextReminder.title || '')}</div>` : '<div>暂未生成提醒</div>'}
</div>
</article>
`
}
const renderPlantGrid = (selector, addCard = false) => {
const box = $(selector)
if (!box) return
const html = state.plants.map(plant => buildPlantCard(plant, plant.id === state.selectedPlantId)).join('')
box.innerHTML = addCard ? `${html}
<article class="plant-card add-card" data-action="add-plant">
<div class="plant-thumb">+</div>
<div class="plant-name">添加植物</div>
<div class="plant-meta">新建植物档案、习性和提醒规则</div>
</article>
` : html
}
const buildReminderItem = reminder => {
const ruleText = reminder.rule && reminder.rule.cycle_text ? reminder.rule.cycle_text : '单次提醒'
const hasRelatedLog = reminder.related_log && reminder.related_log.log_id
return `
<article class="reminder-item" data-type="${escapeHtml(reminder.reminder_type)}" data-reminder-id="${reminder.id}">
<div class="reminder-head">
<div>
<div class="reminder-title">${escapeHtml(reminder.name || '')} · ${escapeHtml(reminder.title || '')}</div>
<div class="photo-meta">${escapeHtml(reminder.due_date)} ${escapeHtml(reminder.due_time || '')} | ${escapeHtml(ruleText)}</div>
</div>
<span class="status-chip">${escapeHtml(reminder.reminder_type || '')}</span>
</div>
<div class="reminder-body">${escapeHtml(reminder.body || '')}</div>
<div class="reminder-actions">
<button class="mini-btn" data-action="resolve-reminder" data-reminder-id="${reminder.id}">完成并记日记</button>
${hasRelatedLog ? `<button class="mini-btn" data-action="view-log-detail" data-log-id="${reminder.related_log.log_id}">查看详情</button>` : ''}
${reminder.rule && reminder.rule.id ? `<button class="mini-btn" data-action="stop-rule" data-rule-id="${reminder.rule.id}">停止此提醒</button>` : ''}
</div>
</article>
`
}
const renderHomeRemindersWeekly = reminders => {
const box = $('#home-reminders-weekly')
if (!box) return
if (!Array.isArray(reminders) || reminders.length === 0) {
box.innerHTML = '<div class="empty-text">未来一周暂无待提醒事项</div>'
return
}
box.innerHTML = reminders.map(buildReminderItem).join('')
}
const renderHomeRemindersImportant = reminders => {
const box = $('#home-reminders-important')
if (!box) return
if (!Array.isArray(reminders) || reminders.length === 0) {
box.innerHTML = '<div class="empty-text">暂无重要提醒</div>'
return
}
box.innerHTML = reminders.map(buildReminderItem).join('')
}
const renderPlantDetail = detail => {
const box = $('#plant-detail')
if (!box) return
if (!detail || !detail.plant) {
box.innerHTML = '<div class="empty-text">请选择一棵植物</div>'
return
}
const plant = detail.plant
const displayAvatar = plant.display_avatar_path || plant.avatar_path || ''
const thumb = displayAvatar
? `<img src="${escapeHtml(displayAvatar)}" alt="${escapeHtml(plant.name)}">`
: '🪴'
const diagnosis = (detail.diagnoses || [])[0]
const activeRules = Array.isArray(detail.active_rules) ? detail.active_rules : []
box.innerHTML = `
<div class="detail-cover">
<div class="plant-thumb">${thumb}</div>
<div>
<div class="detail-title">
<div>
<h3>${escapeHtml(plant.name)}</h3>
<div class="latin">${escapeHtml(plant.scientific_name || '')}</div>
<div class="photo-meta">${escapeHtml(plant.room_area || '')} | ${escapeHtml(plant.light_env || '')}</div>
</div>
<div class="interaction-actions">
<button class="ghost-btn" data-action="edit-plant">编辑</button>
<button class="ghost-btn" data-action="delete-plant">删除植物</button>
</div>
</div>
<div class="detail-grid">
<div class="metric-box"><div class="metric-label">光照</div><div class="metric-value">${escapeHtml(plant.light_text || plant.light_env || '待补充')}</div></div>
<div class="metric-box"><div class="metric-label">浇水</div><div class="metric-value">${escapeHtml(plant.watering_text || `${plant.watering_interval_days || 7}天/次`)}</div></div>
<div class="metric-box"><div class="metric-label">适温</div><div class="metric-value">${escapeHtml(plant.temp_text || '18~28℃')}</div></div>
<div class="metric-box"><div class="metric-label">通风</div><div class="metric-value">${escapeHtml(plant.ventilation_text || '中等通风')}</div></div>
<div class="metric-box"><div class="metric-label">补光灯</div><div class="metric-value">${escapeHtml(plant.grow_light_text || '按需补光')}</div></div>
<div class="metric-box"><div class="metric-label">施肥</div><div class="metric-value">${escapeHtml(plant.fertilizing_text || `${plant.fertilizing_interval_days || 30}天/次`)}</div></div>
</div>
</div>
</div>
<div class="detail-summary">${escapeHtml(plant.ai_summary || plant.note || '这棵植物的详细档案还可以再补充,建议先导入 AI 习性。')}</div>
${diagnosis ? `<div class="detail-summary">最近问诊:${escapeHtml(diagnosis.summary || '')}</div>` : ''}
${activeRules.length ? `<div class="detail-summary">当前提醒:${activeRules.map(rule => `${escapeHtml(rule.title || '')} / ${escapeHtml(rule.cycle_text || '')} / 下次 ${escapeHtml(rule.next_due_date || '')}`).join('<br>')}</div>` : ''}
`
}
const renderLogs = logs => {
const box = $('#log-list')
if (!box) return
if (!Array.isArray(logs) || logs.length === 0) {
box.innerHTML = '<div class="empty-text">暂无日志</div>'
return
}
box.innerHTML = logs.map(log => `
<article class="timeline-item">
<div class="timeline-head">
<div>
<div class="timeline-title">${escapeHtml(log.name || '')} · ${escapeHtml(log.action_label || log.action_type || '')}</div>
<div class="photo-meta">${escapeHtml(log.action_at || '')}${log.mood_tag ? ` | ${escapeHtml(log.mood_tag)}` : ''}</div>
</div>
<div style="display:flex;gap:6px;">
<button class="mini-btn" data-action="view-log-detail" data-log-id="${log.id}">查看详情</button>
<button class="ghost-btn" data-action="delete-log" data-log-id="${log.id}">删除</button>
</div>
</div>
<div class="timeline-body">${escapeHtml(log.note || log.amount_text || '已记录')}</div>
<div class="asset-inline">
${log.asset_summary && log.asset_summary.photo_count ? `<span class="status-chip">图片 ${escapeHtml(log.asset_summary.photo_count)}</span>` : ''}
${log.asset_summary && log.asset_summary.video_count ? `<span class="status-chip">视频 ${escapeHtml(log.asset_summary.video_count)}</span>` : ''}
${log.asset_summary && log.asset_summary.attachment_count ? `<span class="status-chip">附件 ${escapeHtml(log.asset_summary.attachment_count)}</span>` : ''}
${log.reminder_rule ? `<span class="status-chip">提醒 ${escapeHtml(log.reminder_rule.cycle_text || '')}</span>` : ''}
</div>
</article>
`).join('')
}
const renderGallery = photos => {
const box = $('#gallery-list')
if (!box) return
if (!Array.isArray(photos) || photos.length === 0) {
box.innerHTML = '<div class="empty-text">暂无图片</div>'
return
}
box.innerHTML = photos.map(photo => `
<article class="photo-card">
<img src="${escapeHtml(photo.file_path)}" alt="${escapeHtml(photo.name || '')}">
<div class="photo-info">
<div><strong>${escapeHtml(photo.name || '')}</strong></div>
<div class="photo-meta">${escapeHtml(photo.shot_at || '')}</div>
<div class="photo-meta">${escapeHtml(photo.note || '')}</div>
<div class="photo-badges">
${photo.is_home_featured ? '<span class="status-chip">植物封面</span>' : ''}
${photo.photo_source === 'seed' ? '<span class="status-chip">标准图</span>' : ''}
${photo.log_id ? '<span class="status-chip">日志上传</span>' : ''}
</div>
<div class="interaction-actions">
<button class="mini-btn" data-action="diagnose-photo" data-photo-id="${photo.id}">AI问诊</button>
<button class="mini-btn" data-action="toggle-home-photo" data-photo-id="${photo.id}" data-value="${photo.is_home_featured ? 0 : 1}">${photo.is_home_featured ? '取消封面' : '设为植物封面'}</button>
<button class="mini-btn" data-action="delete-photo" data-photo-id="${photo.id}">删除图片</button>
</div>
</div>
</article>
`).join('')
}
const renderSettings = cfg => {
state.settings = cfg
state.uiMode = cfg && cfg.ui_mode || 'debug'
const user = cfg && cfg.user_context || {}
$('#settings-context').textContent = [
user.city || '',
user.residence || '',
user.balcony || '',
user.supplement_light || '',
user.reminder_preference || ''
].filter(Boolean).join(' | ')
}
const loadDashboard = async () => {
const data = await api('/api/plant_home/dashboard')
state.dashboard = data
state.plants = data.plants || []
if (state.selectedPlantId && !state.plants.find(item => item.id === state.selectedPlantId)) state.selectedPlantId = 0
if (!state.selectedPlantId && state.plants[0]) state.selectedPlantId = state.plants[0].id
renderSummary(data.stats)
renderHomeRemindersWeekly(data.reminders_weekly)
renderHomeRemindersImportant(data.reminders_important)
renderPlantGrid('#plant-list', true)
initPlantCardRotators()
// 异步加载天气数据,不阻塞其他内容的渲染
setTimeout(async () => {
try {
const weatherData = await api('/api/plant_home/weather')
renderWeather(weatherData.weather)
} catch (error) {
console.warn('天气数据加载失败:', error)
// 显示默认天气信息
renderWeather(null)
}
}, 0)
}
const loadPlantDetail = async (plantId) => {
if (!plantId) {
renderPlantDetail(null)
return
}
const detail = await api(`/api/plant_home/plants/${plantId}`)
state.selectedPlantId = plantId
renderPlantDetail(detail)
renderPlantGrid('#plant-list', true)
initPlantCardRotators()
}
const loadLogs = async () => {
const data = await api('/api/plant_home/logs')
state.logs = data.data || []
renderLogs(state.logs)
}
const loadPhotos = async () => {
const data = await api('/api/plant_home/photos')
state.photos = data.data || []
renderGallery(state.photos)
}
const loadSettings = async () => {
const data = await api('/api/plant_home/settings')
renderSettings(data.config || {})
}
const loadMoodTags = async () => {
const data = await api('/api/plant_home/mood_tags')
state.moodTags = data.data || []
renderMoodTagPickers()
}
const loadActiveReminderRule = async () => {
const form = $('#log-form')
if (!form) return null
const plantId = form.plant_id.value
const reminderType = form.reminder_type.value || form.action_type.value || 'observe'
if (!plantId || !reminderType) {
state.activeReminderRule = null
$('#log-existing-rule-box').textContent = '当前没有同类提醒'
$('#log-existing-rule-box').classList.remove('is-active')
form.stop_existing_rule_id.value = ''
return null
}
const data = await api(`/api/plant_home/reminder_rules/active?plant_id=${encodeURIComponent(plantId)}&reminder_type=${encodeURIComponent(reminderType)}`)
state.activeReminderRule = data.rule || null
const box = $('#log-existing-rule-box')
box.textContent = summarizeRuleText(state.activeReminderRule)
box.classList.toggle('is-active', !!state.activeReminderRule)
if (!state.activeReminderRule) form.stop_existing_rule_id.value = ''
return state.activeReminderRule
}
const fillPlantOptions = () => {
const plantOptions = state.plants.map(plant => `<option value="${plant.id}">${escapeHtml(plant.name)}</option>`).join('')
$('#log-plant-select').innerHTML = plantOptions
$('#photo-plant-select').innerHTML = plantOptions
if (state.selectedPlantId) {
$('#log-plant-select').value = String(state.selectedPlantId)
$('#photo-plant-select').value = String(state.selectedPlantId)
}
if ($('#log-reminder-type')) $('#log-reminder-type').value = $('#log-action-type')?.value || 'watering'
}
const openPlantDialog = async (edit = false) => {
const form = $('#plant-form')
form.reset()
form.plant_id.value = ''
form.action = 'create'
form.cover_shot_at.value = todayStr()
$('#plant-dialog-title').textContent = edit ? '编辑植物' : '新增植物'
if (edit && state.selectedPlantId) {
await runTrackedInteraction({ title: '读取植物详情用于编辑', mode: 'view' }, async () => {
const detail = await api(`/api/plant_home/plants/${state.selectedPlantId}`)
const plant = detail.plant
form.plant_id.value = plant.id
form.name.value = plant.name || ''
form.alias.value = plant.alias || ''
form.scientific_name.value = plant.scientific_name || ''
form.room_area.value = plant.room_area || ''
form.light_env.value = plant.light_env || ''
form.plant_group.value = plant.plant_group || ''
form.status_label.value = plant.status_label || ''
form.light_text.value = plant.light_text || ''
form.watering_text.value = plant.watering_text || ''
form.temp_text.value = plant.temp_text || ''
form.ventilation_text.value = plant.ventilation_text || ''
form.grow_light_text.value = plant.grow_light_text || ''
form.fertilizing_text.value = plant.fertilizing_text || ''
form.soil_text.value = plant.soil_text || ''
form.ai_summary.value = plant.ai_summary || ''
form.watering_interval_days.value = plant.watering_interval_days || 7
form.fertilizing_interval_days.value = plant.fertilizing_interval_days || 30
form.pruning_interval_days.value = plant.pruning_interval_days || 90
form.repot_interval_days.value = plant.repot_interval_days || 180
let weatherAdjust = {}
try {
weatherAdjust = JSON.parse(plant.weather_adjust_json || '{}')
} catch {
weatherAdjust = {}
}
form.watering_hot_delta.value = weatherAdjust.watering_hot_delta == null ? -1 : weatherAdjust.watering_hot_delta
form.watering_humid_delta.value = weatherAdjust.watering_humid_delta == null ? 1 : weatherAdjust.watering_humid_delta
form.watering_cold_delta.value = weatherAdjust.watering_cold_delta == null ? 2 : weatherAdjust.watering_cold_delta
form.note.value = plant.note || ''
})
} else {
form.repot_interval_days.value = 180
form.watering_hot_delta.value = -1
form.watering_humid_delta.value = 1
form.watering_cold_delta.value = 2
}
openDialog('plant-dialog')
}
const handlePlantSubmit = async event => {
event.preventDefault()
const form = event.target
const submitButton = form.querySelector('button[type="submit"]')
await withButtonLoading(submitButton, async () => {
const fd = new FormData(form)
const file = fd.get('cover_photo')
const payload = Object.fromEntries(fd.entries())
delete payload.cover_photo
await runTrackedInteraction({ title: payload.plant_id ? '修改植物档案' : '新增植物档案', mode: 'normal' }, async () => {
let plant = null
if (payload.plant_id) {
const data = await api(`/api/plant_home/plants/${payload.plant_id}`, {
method: 'PUT',
body: JSON.stringify(payload)
})
plant = data.plant || null
} else {
const data = await api('/api/plant_home/plants', {
method: 'POST',
body: JSON.stringify(payload)
})
plant = data.plant || null
}
if (plant && file instanceof File && file.size > 0) {
const photoFd = new FormData()
photoFd.append('plant_id', String(plant.id))
photoFd.append('photo', file)
photoFd.append('shot_at', payload.cover_shot_at || todayStr())
photoFd.append('note', payload.cover_note || '')
if (payload.cover_make_avatar === '1') photoFd.append('make_avatar', '1')
await api('/api/plant_home/photos/upload', {
method: 'POST',
body: photoFd
})
}
closeDialog('plant-dialog')
await refreshAll('刷新植物与提醒数据')
})
})
}
const deletePlant = async () => {
if (!state.selectedPlantId) return
const plant = state.plants.find(item => item.id === state.selectedPlantId)
const confirmed = await openDeleteConfirmDialog({
title: '删除植物',
message: `确认删除“${plant && plant.name ? plant.name : '当前植物'}”吗?这会同时删除日志、图片、提醒和 AI 档案。`,
detail: {
plant_id: state.selectedPlantId,
plant_name: plant && plant.name
}
})
if (!confirmed) return
const deleteButton = document.querySelector('[data-action="delete-plant"]')
await withButtonLoading(deleteButton, async () => {
await runTrackedInteraction({ title: '删除植物', mode: 'normal' }, async () => {
await api(`/api/plant_home/plants/${state.selectedPlantId}`, { method: 'DELETE' })
state.selectedPlantId = 0
await refreshAll('刷新植物、日志、相册与提醒数据')
})
})
}
const deleteLog = async logId => {
const target = state.logs.find(item => item.id === logId)
const confirmed = await openDeleteConfirmDialog({
title: '删除养护日志',
message: '确认删除这条养护日志吗?',
detail: target || { id: logId }
})
if (!confirmed) return
const deleteButton = document.querySelector(`[data-action="delete-log"][data-log-id="${logId}"]`)
await withButtonLoading(deleteButton, async () => {
try {
await runTrackedInteraction({ title: '删除养护日志', mode: 'normal' }, async () => {
await api(`/api/plant_home/logs/${logId}`, { method: 'DELETE' })
await refreshAll('刷新日志与相关数据')
})
} catch (error) {
if (String(error && error.message || '') === 'log_not_found') {
state.logs = state.logs.filter(item => item.id !== logId)
renderLogs(state.logs)
await refreshAll('刷新日志与相关数据')
await runTrackedInteraction({ title: '删除养护日志', mode: 'view' }, async session => {
appendInteractionLine(session, '[提示]', {
message: '这条旧日志已经不在数据库中,页面已自动刷新。'
})
})
return
}
throw error
}
})
}
const deletePhoto = async photoId => {
const target = state.photos.find(item => item.id === photoId)
const confirmed = await openDeleteConfirmDialog({
title: '删除植物图片',
message: '确认删除这张图片吗?会同时移除相关 AI 问诊记录。',
detail: target || { id: photoId }
})
if (!confirmed) return
const deleteButton = document.querySelector(`[data-action="delete-photo"][data-photo-id="${photoId}"]`)
await withButtonLoading(deleteButton, async () => {
await runTrackedInteraction({ title: '删除植物图片', mode: 'normal' }, async () => {
await api(`/api/plant_home/photos/${photoId}`, { method: 'DELETE' })
await refreshAll('刷新相册与植物详情数据')
})
})
}
const toggleHomePhoto = async (photoId, nextValue) => {
const target = state.photos.find(item => item.id === photoId)
const toggleButton = document.querySelector(`[data-action="toggle-home-photo"][data-photo-id="${photoId}"]`)
await withButtonLoading(toggleButton, async () => {
await runTrackedInteraction({ title: nextValue ? '设置植物封面' : '取消植物封面', mode: 'normal' }, async () => {
await api(`/api/plant_home/photos/${photoId}/home_feature`, {
method: 'PUT',
body: JSON.stringify({ value: Number(nextValue) === 1 })
})
await refreshAll(`刷新植物封面与相册${target && target.name ? `:${target.name}` : ''}`)
})
})
}
const resetLogForm = async () => {
const form = $('#log-form')
form.reset()
form.action_at.value = todayStr()
form.override_existing_rule.value = '0'
form.stop_existing_rule_id.value = ''
fillPlantOptions()
$('#log-reminder-type').value = $('#log-action-type').value || 'watering'
$('#log-reminder-enabled').checked = false
updateReminderEnabledFields()
updateReminderModeFields()
syncWeeklyDaysField()
await loadActiveReminderRule()
renderMoodTagPickers()
}
const addMoodTagFromDialog = async () => {
const input = $('#log-new-tag-name')
const name = input.value.trim()
if (!name) throw new Error('tag_name_required')
await api('/api/plant_home/mood_tags', {
method: 'POST',
body: JSON.stringify({ name })
})
input.value = ''
await loadMoodTags()
applyMoodTagToForm('#log-form [name="mood_tag"]', name)
}
const stopActiveRuleFromLog = async () => {
const rule = state.activeReminderRule
if (!rule || !rule.id) return
const confirmed = await openConfirmDialog({
title: '停止现有提醒',
message: '确认停止当前同类提醒吗?停止后不会再继续循环。',
detail: toPrettyText(rule),
confirmText: '确认停止'
})
if (!confirmed) return
await api(`/api/plant_home/reminder_rules/${rule.id}/stop`, { method: 'POST' })
$('#log-form').stop_existing_rule_id.value = String(rule.id)
state.activeReminderRule = null
await loadActiveReminderRule()
}
const handleLogSubmit = async event => {
event.preventDefault()
const form = event.target
const submitButton = form.querySelector('button[type="submit"]')
await withButtonLoading(submitButton, async () => {
syncWeeklyDaysField()
const fd = new FormData(form)
const reminderEnabled = String(fd.get('reminder_enabled') || '') === '1'
const activeRule = state.activeReminderRule
if (reminderEnabled && activeRule && String(fd.get('override_existing_rule') || '0') !== '1') {
const confirmed = await openConfirmDialog({
title: '覆盖旧提醒',
message: '当前已有同类提醒,是否用本次设置覆盖?',
detail: toPrettyText(activeRule),
confirmText: '覆盖旧提醒'
})
if (!confirmed) return
fd.set('override_existing_rule', '1')
form.override_existing_rule.value = '1'
}
await runTrackedInteraction({ title: '新增生长日记', mode: 'normal' }, async () => {
await api('/api/plant_home/logs', {
method: 'POST',
body: fd
})
closeDialog('log-dialog')
await refreshAll('刷新日志与提醒数据')
})
})
}
const handlePhotoSubmit = async event => {
event.preventDefault()
const form = event.target
const submitButton = form.querySelector('button[type="submit"]')
await withButtonLoading(submitButton, async () => {
const fd = new FormData(form)
await runTrackedInteraction({ title: '上传植物照片', mode: 'normal' }, async () => {
const data = await api('/api/plant_home/photos/upload', {
method: 'POST',
body: fd
})
closeDialog('photo-dialog')
await refreshAll('刷新相册与植物数据')
if (data.diagnosis) {
showAnalysis(`AI 问诊结果\n\n${data.diagnosis.summary || ''}\n\n建议:${JSON.parse(data.diagnosis.suggestions_json || '[]').join(';')}`, { inline: true })
}
})
})
}
const openLogDetailDialog = async logId => {
const dialog = $('#log-detail-dialog')
const content = $('#log-detail-content')
if (!dialog || !content) return
content.innerHTML = '<div class="empty-text">加载中...</div>'
openDialog('log-detail-dialog')
try {
const data = await api(`/api/plant_home/logs/${logId}`)
const log = data.log
if (!log) {
content.innerHTML = '<div class="empty-text">日志不存在或已被删除</div>'
return
}
const photos = Array.isArray(log.photos) ? log.photos : []
const attachments = Array.isArray(log.attachments) ? log.attachments : []
const rule = log.reminder_rule
content.innerHTML = `
<div class="log-detail-section">
<div class="log-detail-field">
<span class="log-detail-label">植物</span>
<span>${escapeHtml(log.name || '')}</span>
</div>
<div class="log-detail-field">
<span class="log-detail-label">操作</span>
<span>${escapeHtml(log.action_label || '')}</span>
</div>
<div class="log-detail-field">
<span class="log-detail-label">日期</span>
<span>${escapeHtml(log.action_at || '')}</span>
</div>
${log.mood_tag ? `<div class="log-detail-field"><span class="log-detail-label">心情</span><span>${escapeHtml(log.mood_tag)}</span></div>` : ''}
</div>
${log.note ? `<div class="log-detail-section"><div class="log-detail-label">备注</div><div class="log-detail-note">${escapeHtml(log.note)}</div></div>` : ''}
${photos.length > 0 ? `
<div class="log-detail-section">
<div class="log-detail-label">图片(${photos.length})</div>
<div class="log-detail-photos">
${photos.map(p => `<img src="${escapeHtml(p.file_path)}" alt="" class="log-detail-photo" onclick="window.open(this.src)">`).join('')}
</div>
</div>
` : ''}
${attachments.length > 0 ? `
<div class="log-detail-section">
<div class="log-detail-label">附件</div>
<div class="log-detail-attachments">
${attachments.map(a => `<div class="log-detail-attachment">${escapeHtml(a.file_name)}</div>`).join('')}
</div>
</div>
` : ''}
${rule ? `
<div class="log-detail-section">
<div class="log-detail-label">关联提醒</div>
<div class="log-detail-note">${escapeHtml(rule.title || '')} · ${escapeHtml(rule.cycle_text || '')}</div>
</div>
` : ''}
`
} catch (e) {
content.innerHTML = `<div class="empty-text">加载失败:${escapeHtml(String(e.message || e))}</div>`
}
}
const openReminderDialog = reminderId => {
const form = $('#reminder-form')
form.reset()
form.reminder_id.value = String(reminderId || '')
form.action_at.value = todayStr()
renderMoodTagPickers()
openDialog('reminder-dialog')
}
const handleReminderSubmit = async event => {
event.preventDefault()
const form = event.target
const submitButton = form.querySelector('button[type="submit"]')
await withButtonLoading(submitButton, async () => {
const payload = Object.fromEntries(new FormData(form).entries())
await runTrackedInteraction({ title: '完成提醒并写入日记', mode: 'normal' }, async () => {
await api(`/api/plant_home/reminders/${payload.reminder_id}/resolve`, {
method: 'POST',
body: JSON.stringify({
note: payload.note || '',
mood_tag: payload.mood_tag || '',
action_at: payload.action_at || todayStr()
})
})
closeDialog('reminder-dialog')
await refreshAll('刷新提醒与生长日志')
})
})
}
const requestNotification = async () => {
const notifyButton = $('#btn-enable-notify')
await withButtonLoading(notifyButton, async () => {
await runTrackedInteraction({ title: '申请浏览器提醒权限', mode: 'normal' }, async session => {
if (typeof Notification === 'undefined') {
appendInteractionLine(session, '[本地能力检查]', '当前浏览器不支持 Notification')
$('#notify-status').textContent = '当前浏览器不支持 Notification'
return
}
appendInteractionLine(session, '[本地请求]', {
action: 'Notification.requestPermission',
before_status: Notification.permission
})
const result = await Notification.requestPermission()
appendInteractionLine(session, '[本地返回]', {
after_status: result
})
state.notificationGranted = result === 'granted'
$('#notify-status').textContent = state.notificationGranted ? '已授权' : `当前状态:${result}`
})
})
}
const pollNotifications = async () => {
if (!state.notificationGranted) return
const data = await api('/api/plant_home/notifications/pending')
const items = data.data || []
for (const item of items) {
const notification = new Notification(item.title || '养护提醒', {
body: item.body || '',
tag: `plant-home-${item.id}`
})
notification.onclick = () => window.focus()
await api(`/api/plant_home/notifications/${item.id}/ack`, { method: 'POST' })
}
}
const refreshAll = async (title = '刷新页面数据') => {
await runTrackedInteraction({ title, mode: 'view' }, async session => {
appendInteractionLine(session, '[刷新范围]', {
modules: ['dashboard', 'logs', 'photos', 'settings', 'plant_detail', 'mood_tags']
})
await loadDashboard()
fillPlantOptions()
await Promise.all([
loadLogs(),
loadPhotos(),
loadSettings(),
loadPlantDetail(state.selectedPlantId),
loadMoodTags()
])
})
}
const importProfile = async () => {
if (!state.selectedPlantId) return
const importButton = document.getElementById('btn-import-profile')
await withButtonLoading(importButton, async () => {
await runTrackedInteraction({ title: 'AI 导入植物习性档案', mode: 'normal' }, async () => {
const data = await api(`/api/plant_home/plants/${state.selectedPlantId}/import_profile`, { method: 'POST' })
await refreshAll('刷新植物档案与提醒')
showAnalysis(JSON.stringify(data.profile || {}, null, 2), { inline: true })
})
})
}
const analyzePattern = async () => {
if (!state.selectedPlantId) return
const analyzeButton = document.getElementById('btn-pattern-analysis')
await withButtonLoading(analyzeButton, async () => {
await runTrackedInteraction({ title: 'AI 分析养护规律', mode: 'normal' }, async () => {
const data = await api(`/api/plant_home/plants/${state.selectedPlantId}/analyze_pattern`, { method: 'POST' })
showAnalysis(data.analysis || '暂无结果', { inline: true })
})
})
}
const runScheduler = async () => {
const schedulerButton = document.getElementById('btn-run-scheduler')
await withButtonLoading(schedulerButton, async () => {
await runTrackedInteraction({ title: '手动执行提醒调度器', mode: 'normal' }, async () => {
const data = await api('/api/plant_home/scheduler/run', { method: 'POST' })
await refreshAll('刷新天气与提醒结果')
showAnalysis(`手动调度完成\n\n新生成提醒:${(data.generate && data.generate.created) || 0}\n天气:${JSON.stringify(data.weather || {}, null, 2)}`, { inline: true })
})
})
}
const switchTab = tab => {
$$('.tab-btn').forEach(btn => btn.classList.toggle('active', btn.dataset.tab === tab))
$$('.tab-panel').forEach(panel => panel.classList.toggle('active', panel.id === `tab-${tab}`))
}
const bindEvents = () => {
$$('.tab-btn').forEach(btn => {
btn.addEventListener('click', () => switchTab(btn.dataset.tab))
})
document.body.addEventListener('click', async event => {
const target = event.target
if (target.matches('[data-close-dialog]')) {
closeDialog(target.dataset.closeDialog)
return
}
const plantCard = target.closest('.plant-card[data-plant-id]')
if (plantCard) {
const plantId = parseInt(String(plantCard.dataset.plantId || '0'), 10)
if (plantId) {
await runTrackedInteraction({ title: '查看植物详情', mode: 'view' }, async () => {
await loadPlantDetail(plantId)
})
}
return
}
if (target.closest('[data-action="add-plant"]')) return openPlantDialog(false)
if (target.closest('[data-action="edit-plant"]')) return openPlantDialog(true)
if (target.closest('[data-action="delete-plant"]')) return deletePlant()
if (target.closest('[data-action="resolve-reminder"]')) {
const id = parseInt(target.dataset.reminderId || target.closest('[data-reminder-id]').dataset.reminderId, 10)
return openReminderDialog(id)
}
if (target.closest('[data-action="stop-rule"]')) {
const ruleId = parseInt(target.closest('[data-action="stop-rule"]').dataset.ruleId || '0', 10)
if (!ruleId) return
const confirmed = await openConfirmDialog({
title: '停止循环提醒',
message: '确认停止这个提醒规则吗?',
detail: toPrettyText({ rule_id: ruleId }),
confirmText: '确认停止'
})
if (!confirmed) return
await api(`/api/plant_home/reminder_rules/${ruleId}/stop`, { method: 'POST' })
return refreshAll('刷新提醒与日志数据')
}
if (target.closest('[data-action="pick-mood-tag"]')) {
const btn = target.closest('[data-action="pick-mood-tag"]')
return applyMoodTagToForm(btn.dataset.targetInputSelector, btn.dataset.tagValue || '')
}
if (target.id === 'btn-log-add-tag') {
const addButton = target
return withButtonLoading(addButton, async () => addMoodTagFromDialog())
}
if (target.closest('[data-action="diagnose-photo"]')) {
const btn = target.closest('[data-action="diagnose-photo"]')
const photoId = parseInt(btn.dataset.photoId || '0', 10)
const diagnoseButton = btn
return withButtonLoading(diagnoseButton, async () => {
return runTrackedInteraction({ title: 'AI 图片问诊', mode: 'normal' }, async () => {
const data = await api(`/api/plant_home/photos/${photoId}/diagnose`, { method: 'POST' })
const suggestions = JSON.parse(data.diagnosis && data.diagnosis.suggestions_json || '[]')
showAnalysis(`${data.diagnosis && data.diagnosis.summary || ''}\n\n建议:\n- ${suggestions.join('\n- ')}`, { inline: true })
})
})
}
if (target.closest('[data-action="delete-photo"]')) {
const photoId = parseInt(target.closest('[data-action="delete-photo"]').dataset.photoId || '0', 10)
return deletePhoto(photoId)
}
if (target.closest('[data-action="toggle-home-photo"]')) {
const btn = target.closest('[data-action="toggle-home-photo"]')
const photoId = parseInt(btn.dataset.photoId || '0', 10)
const nextValue = parseInt(btn.dataset.value || '0', 10)
return toggleHomePhoto(photoId, nextValue)
}
if (target.closest('[data-action="delete-log"]')) {
const logId = parseInt(target.closest('[data-action="delete-log"]').dataset.logId || '0', 10)
return deleteLog(logId)
}
})
$('#plant-form').addEventListener('submit', handlePlantSubmit)
$('#log-form').addEventListener('submit', handleLogSubmit)
$('#photo-form').addEventListener('submit', handlePhotoSubmit)
$('#reminder-form').addEventListener('submit', handleReminderSubmit)
$('#btn-assist-plant').addEventListener('click', () => {
const assistButton = $('#btn-assist-plant')
withButtonLoading(assistButton, async () => {
await assistPlantDraft()
})
})
$('#btn-add-plant').addEventListener('click', () => openPlantDialog(false))
$('#btn-add-log').addEventListener('click', async () => {
await resetLogForm()
openDialog('log-dialog')
})
$('#btn-add-log-logs').addEventListener('click', async () => {
await resetLogForm()
openDialog('log-dialog')
})
$('#btn-upload-photo').addEventListener('click', () => {
$('#photo-form').reset()
$('#photo-form').shot_at.value = todayStr()
fillPlantOptions()
openDialog('photo-dialog')
})
$('#log-form').plant_id.addEventListener('change', () => loadActiveReminderRule().catch(() => {}))
$('#log-form').action_type.addEventListener('change', () => {
$('#log-reminder-type').value = $('#log-action-type').value || 'watering'
loadActiveReminderRule().catch(() => {})
})
$('#log-form').reminder_type.addEventListener('change', () => loadActiveReminderRule().catch(() => {}))
$('#log-form').reminder_rule_mode.addEventListener('change', updateReminderModeFields)
$('#log-reminder-enabled').addEventListener('change', updateReminderEnabledFields)
$('#log-weekday-list').addEventListener('change', syncWeeklyDaysField)
$('#log-existing-rule-box').addEventListener('click', () => {
if (state.activeReminderRule) stopActiveRuleFromLog().catch(() => {})
})
$('#btn-import-profile').addEventListener('click', importProfile)
$('#btn-pattern-analysis').addEventListener('click', analyzePattern)
$('#btn-refresh-dashboard').addEventListener('click', () => refreshAll('手动刷新首页与详情数据'))
// 提醒 tab 切换
document.querySelectorAll('.reminder-tab-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.reminder-tab-btn').forEach(b => b.classList.remove('active'))
btn.classList.add('active')
const tab = btn.dataset.reminderTab
document.querySelectorAll('[id^="home-reminders-"]').forEach(el => el.classList.add('hidden'))
const target = $(`#home-reminders-${tab}`)
if (target) target.classList.remove('hidden')
})
})
// 查看日志详情(委托:首页提醒 + 养护日志列表)
document.querySelector('.plant-main').addEventListener('click', async e => {
const btn = e.target.closest('[data-action="view-log-detail"]')
if (!btn) return
const logId = parseInt(btn.dataset.logId || '0', 10)
if (logId) await openLogDetailDialog(logId)
})
$('#btn-refresh-weather').addEventListener('click', async () => {
const weatherButton = $('#btn-refresh-weather')
await withButtonLoading(weatherButton, async () => {
await runTrackedInteraction({ title: '刷新天气数据', mode: 'normal' }, async () => {
await api('/api/plant_home/weather/refresh', { method: 'POST' })
await refreshAll('刷新天气与页面数据')
})
})
})
$('#btn-run-scheduler').addEventListener('click', runScheduler)
$('#btn-enable-notify').addEventListener('click', requestNotification)
$('#btn-copy-interaction').addEventListener('click', async () => {
try {
await copyInteractionLog()
setInteractionStatus('交互日志已复制到剪贴板', 'success')
} catch (error) {
setInteractionStatus(`复制失败:${error.message || error}`, 'error')
}
})
$('#btn-copy-confirm').addEventListener('click', async () => {
const text = [$('#confirm-title').textContent, $('#confirm-message').textContent, $('#confirm-detail').textContent].join('\n\n')
try {
await navigator.clipboard.writeText(text)
} catch {}
})
$('#btn-cancel-confirm').addEventListener('click', () => resolveConfirmDialog(false))
$('#btn-ok-confirm').addEventListener('click', () => resolveConfirmDialog(true))
}
const init = async () => {
bindEvents()
$('#notify-status').textContent = state.notificationGranted ? '已授权' : '尚未授权'
try {
await refreshAll('初始化绿植的流浪家园')
} catch (e) {
showAnalysis(`初始化失败:${e.message || e}`, { inline: true })
}
const interval = (state.settings && state.settings.notification && state.settings.notification.browser_poll_seconds) || 60
state.notificationTimer = window.setInterval(() => {
pollNotifications().catch(() => {})
}, Math.max(15, Number(interval)) * 1000)
pollNotifications().catch(() => {})
}
window.addEventListener('load', init)