更新若干功能

This commit is contained in:
李琦
2026-08-14 07:52:01 +08:00
parent 153c7ed448
commit 89bc265f68
196 changed files with 17675 additions and 0 deletions

BIN
frontend/public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

View File

@@ -0,0 +1,66 @@
<script setup>
import { onMounted, onUnmounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { Sparkles, RefreshCw } from 'lucide-vue-next'
import { call, on } from '../api'
import { useAppStore } from '../store'
import MarkdownView from './MarkdownView.vue'
// 模块 AI 分析卡片:展示分析后自动生成的模块介绍,支持手动重新生成。
const props = defineProps({
projectId: { type: Number, required: true },
kind: { type: String, default: 'project' } // project | git | structure | insights
})
const { t } = useI18n()
const store = useAppStore()
const sum = ref(null)
const busy = ref(false)
let offSummary = null
const titleKey = { project: 'aiBriefProject', git: 'aiBriefGit', structure: 'aiBriefStructure', insights: 'aiBriefInsights' }
async function load() {
const list = await call('GetAISummaries', props.projectId)
sum.value = list.find(x => x.kind === props.kind) || null
}
async function regen() {
if (busy.value) return
busy.value = true
try {
await call('RegenerateAISummary', props.projectId, props.kind)
} catch (e) {
busy.value = false
const code = String(e).split(':')[0].trim()
store.showToast({ type: 'error', text: t('errors.' + code) !== 'errors.' + code ? t('errors.' + code) : String(e) })
}
}
// RFC3339(UTC) 转本地时区显示
const fmtTime = at => {
const d = new Date(at)
if (isNaN(d)) return at || ''
const p = n => String(n).padStart(2, '0')
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`
}
onMounted(async () => {
await load()
offSummary = on('ai:summary', async e => {
if (e.projectId !== props.projectId || e.kind !== props.kind) return
busy.value = false
if (!e.error) await load()
})
})
onUnmounted(() => offSummary?.())
</script>
<template>
<section class="panel ai-brief">
<header class="ai-brief-head">
<b><Sparkles />{{ t(titleKey[kind] || 'aiBriefProject') }}</b>
<time v-if="sum">{{ fmtTime(sum.generatedAt) }}</time>
<button class="ai-brief-regen" :class="{ busy }" :title="t('aiBriefRegen')" :disabled="busy" @click="regen"><RefreshCw /></button>
</header>
<MarkdownView v-if="sum?.content" class="ai-brief-body" :source="sum.content" />
<p v-else class="ai-brief-empty">{{ busy ? t('aiBriefGenerating') : t('aiBriefEmpty') }}</p>
</section>
</template>

View File

@@ -0,0 +1,92 @@
<script setup>
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { Sparkles, RefreshCw, NotebookPen, Settings } from 'lucide-vue-next'
import { call, on } from '../api'
import { useAppStore } from '../store'
import MarkdownView from './MarkdownView.vue'
// 工作台 AI 简报:根据今天的待办/工单生成工作规划,下班时一键总结日报。
const router = useRouter()
const store = useAppStore()
const { t } = useI18n()
const plan = ref(null)
const report = ref(null)
const busy = ref('') // '' | dayplan | dayreport
let offSummary = null
const hasKey = computed(() => store.settings.aiProvider === 'deepseek' ? !!store.settings.deepSeekKey : !!store.settings.sparkKey)
async function load() {
const list = await call('GetAISummaries', 0)
plan.value = list.find(x => x.kind === 'dayplan') || null
report.value = list.find(x => x.kind === 'dayreport') || null
}
async function gen(kind) {
if (busy.value) return
busy.value = kind
try {
await call('RegenerateAISummary', 0, kind)
} catch (e) {
busy.value = ''
const code = String(e).split(':')[0].trim()
store.showToast({ type: 'error', text: t('errors.' + code) !== 'errors.' + code ? t('errors.' + code) : String(e) })
}
}
const fmtTime = at => {
const d = new Date(at)
if (isNaN(d)) return ''
const p = n => String(n).padStart(2, '0')
return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`
}
// 昨天生成的内容仍会展示,但提示已过期,鼓励重新生成
const isToday = at => {
const d = new Date(at)
return !isNaN(d) && d.toDateString() === new Date().toDateString()
}
onMounted(async () => {
await load()
offSummary = on('ai:summary', async e => {
if (e.projectId !== 0 || (e.kind !== 'dayplan' && e.kind !== 'dayreport')) return
if (e.kind === busy.value) busy.value = ''
if (!e.error) await load()
})
})
onUnmounted(() => offSummary?.())
</script>
<template>
<section class="panel ai-day">
<div v-if="!hasKey" class="ai-day-nokey">
<Sparkles />
<p>{{ t('wbAiNoKey') }}</p>
<button class="btn secondary" @click="router.push('/settings?tab=ai')"><Settings />{{ t('aiConfigureNow') }}</button>
</div>
<div v-else class="ai-day-grid">
<div class="ai-day-block">
<header>
<b><Sparkles />{{ t('wbAiPlan') }}</b>
<time v-if="plan" :class="{ stale: !isToday(plan.generatedAt) }">{{ fmtTime(plan.generatedAt) }}</time>
<button class="btn secondary" :disabled="!!busy" @click="gen('dayplan')">
<RefreshCw :class="{ spin: busy === 'dayplan' }" />{{ busy === 'dayplan' ? t('wbGenerating') : (plan ? t('wbPlanRegen') : t('wbPlanGen')) }}
</button>
</header>
<MarkdownView v-if="plan?.content" class="ai-day-body" :source="plan.content" />
<p v-else class="ai-day-empty">{{ busy === 'dayplan' ? t('wbGenerating') : t('wbPlanEmpty') }}</p>
</div>
<div class="ai-day-block">
<header>
<b><NotebookPen />{{ t('wbReport') }}</b>
<time v-if="report" :class="{ stale: !isToday(report.generatedAt) }">{{ fmtTime(report.generatedAt) }}</time>
<button class="btn primary" :disabled="!!busy" @click="gen('dayreport')">
<NotebookPen :class="{ spin: busy === 'dayreport' }" />{{ busy === 'dayreport' ? t('wbGenerating') : t('wbReportGen') }}
</button>
</header>
<MarkdownView v-if="report?.content" class="ai-day-body" :source="report.content" />
<p v-else class="ai-day-empty">{{ busy === 'dayreport' ? t('wbGenerating') : t('wbReportEmpty') }}</p>
</div>
</div>
</section>
</template>

View File

@@ -0,0 +1,101 @@
<script setup>
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { Sparkles, RefreshCw, X, Settings } from 'lucide-vue-next'
import { call, on } from '../api'
import { useAppStore } from '../store'
import MarkdownView from './MarkdownView.vue'
// 页面级 AI 总结抽屉launchpad / logs / calendar / config / notes 共用。
// 结果存 day_briefsprojectID=0生成走 RegenerateAISummary + ai:summary 事件。
const props = defineProps({
kind: { type: String, required: true },
title: { type: String, default: '' }
})
const emit = defineEmits(['close'])
const router = useRouter()
const store = useAppStore()
const { t } = useI18n()
const summary = ref(null)
const busy = ref(false)
const err = ref('')
let offSummary = null
const hasKey = computed(() => store.settings.aiProvider === 'deepseek' ? !!store.settings.deepSeekKey : !!store.settings.sparkKey)
async function load() {
try {
const list = await call('GetAISummaries', 0)
summary.value = list.find(x => x.kind === props.kind) || null
} catch { /* 后端未就绪时静默 */ }
}
async function gen() {
if (busy.value) return
err.value = ''
busy.value = true
try {
await call('RegenerateAISummary', 0, props.kind)
} catch (e) {
busy.value = false
const code = String(e).split(':')[0].trim()
err.value = t('errors.' + code) !== 'errors.' + code ? t('errors.' + code) : String(e)
}
}
const fmtTime = at => {
const d = new Date(at)
if (isNaN(d)) return ''
const p = n => String(n).padStart(2, '0')
return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`
}
function goKey() {
emit('close')
router.push('/settings?tab=ai')
}
function onKey(e) {
if (e.key === 'Escape') { e.stopPropagation(); emit('close') }
}
onMounted(async () => {
addEventListener('keydown', onKey)
await load()
offSummary = on('ai:summary', async e => {
if (e.projectId !== 0 || e.kind !== props.kind) return
busy.value = false
if (e.error) err.value = e.error
else await load()
})
})
onUnmounted(() => { removeEventListener('keydown', onKey); offSummary?.() })
</script>
<template>
<Teleport to="body">
<div class="overlay scope-overlay" @click.self="emit('close')">
<aside class="scope-drawer">
<header class="scope-head">
<b><Sparkles />{{ t('aiScopeBtn') }}<span v-if="title" class="scope-title">· {{ title }}</span></b>
<time v-if="summary">{{ fmtTime(summary.generatedAt) }}</time>
<button type="button" class="nm-close" :title="t('close')" @click="emit('close')"><X /></button>
</header>
<div class="scope-body">
<div v-if="!hasKey" class="scope-nokey">
<Sparkles />
<p>{{ t('wbAiNoKey') }}</p>
<button class="btn secondary" @click="goKey"><Settings />{{ t('aiConfigureNow') }}</button>
</div>
<template v-else>
<MarkdownView v-if="summary?.content" :source="summary.content" />
<p v-else-if="!busy" class="scope-empty">{{ t('aiScopeEmpty') }}</p>
<p v-if="busy" class="scope-generating"><RefreshCw class="spin" />{{ t('wbGenerating') }}</p>
<p v-if="err" class="scope-err">{{ err }}</p>
</template>
</div>
<footer v-if="hasKey" class="scope-foot">
<button class="btn" :disabled="busy" @click="gen">
<RefreshCw :class="{ spin: busy }" />{{ busy ? t('wbGenerating') : (summary ? t('aiScopeRegen') : t('aiScopeGen')) }}
</button>
</footer>
</aside>
</div>
</Teleport>
</template>

View File

@@ -0,0 +1,53 @@
<script setup>
import { onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { X, BarChart2, GitBranch, ListTodo, CalendarDays, Bot, CloudUpload } from 'lucide-vue-next'
import { call } from '../api'
import logoUrl from '../assets/logo.png'
// 关于弹窗:由原生菜单“帮助 → 关于”触发menu:action=about
const emit = defineEmits(['close'])
const { t } = useI18n()
const version = ref('')
const aboutFeatures = [
{ icon: BarChart2, key: 'aboutFeatCode' },
{ icon: GitBranch, key: 'aboutFeatGit' },
{ icon: ListTodo, key: 'aboutFeatTask' },
{ icon: CalendarDays, key: 'aboutFeatCal' },
{ icon: Bot, key: 'aboutFeatAI' },
{ icon: CloudUpload, key: 'aboutFeatSync' }
]
onMounted(async () => {
try { version.value = await call('GetAppVersion') } catch { version.value = '' }
})
</script>
<template>
<Teleport to="body">
<div class="overlay" @click.self="emit('close')">
<section class="modal about-modal">
<button type="button" class="about-close" :aria-label="t('close')" @click="emit('close')"><X /></button>
<div class="about-hero">
<span class="about-glow" aria-hidden="true"></span>
<img class="about-logo" :src="logoUrl" alt="logo" />
<h2 class="about-name">{{ t('app') }}</h2>
<p class="about-slogan">{{ t('aboutSlogan') }}</p>
<div class="about-badges">
<span v-if="version" class="about-badge ver">v{{ version }}</span>
<span class="about-badge">NL PMS</span>
<span class="about-badge">Wails v3 · Go · Vue 3</span>
</div>
</div>
<p class="about-intro">{{ t('aboutIntro') }}</p>
<div class="about-feats">
<div v-for="f in aboutFeatures" :key="f.key" class="about-feat"><component :is="f.icon" /><span>{{ t(f.key) }}</span></div>
</div>
<div class="about-story">
<b>{{ t('aboutNameTitle') }}</b>
<p>{{ t('aboutNameStory') }}</p>
</div>
<p class="about-foot">{{ t('aboutFoot') }}</p>
</section>
</div>
</Teleport>
</template>

View File

@@ -0,0 +1,103 @@
<script setup>
import { computed, onMounted, onUnmounted, ref, watch, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { Search, Folder, ListTodo, TicketCheck, Bot, CornerDownLeft } from 'lucide-vue-next'
import { call } from '../api'
import { useAppStore } from '../store'
const store = useAppStore()
const router = useRouter()
const { t } = useI18n()
const q = ref('')
const hits = ref([])
const sel = ref(0)
const busy = ref(false)
const searched = ref(false)
const input = ref(null)
const listEl = ref(null)
let timer = null
const kindMeta = {
project: { icon: Folder, label: 'kindProject' },
todo: { icon: ListTodo, label: 'kindTodo' },
ticket: { icon: TicketCheck, label: 'kindTicket' },
conversation: { icon: Bot, label: 'kindConversation' }
}
const groups = computed(() => {
const by = {}
const g = []
for (const h of hits.value) {
if (!by[h.kind]) { by[h.kind] = { kind: h.kind, items: [] }; g.push(by[h.kind]) }
by[h.kind].items.push(h)
}
return g
})
watch(q, () => {
clearTimeout(timer)
timer = setTimeout(search, 180)
})
async function search() {
const s = q.value.trim()
if (!s) { hits.value = []; sel.value = 0; searched.value = false; return }
busy.value = true
try {
hits.value = await call('GlobalSearch', s) || []
sel.value = 0
searched.value = true
} catch { hits.value = [] }
busy.value = false
}
function close() { store.paletteOpen = false }
function go(h) {
close()
if (h.kind === 'project') router.push(`/project/${h.id}`)
else if (h.kind === 'todo') router.push('/todos')
else if (h.kind === 'ticket') router.push('/tickets')
else router.push({ path: '/ai', query: { conversation: h.id } })
}
function onKey(e) {
if (e.key === 'Escape') { e.preventDefault(); close() }
else if (e.key === 'ArrowDown') { e.preventDefault(); move(1) }
else if (e.key === 'ArrowUp') { e.preventDefault(); move(-1) }
else if (e.key === 'Enter' && hits.value[sel.value]) { e.preventDefault(); go(hits.value[sel.value]) }
}
function move(d) {
if (!hits.value.length) return
sel.value = (sel.value + d + hits.value.length) % hits.value.length
nextTick(() => listEl.value?.querySelector('.cp-item.active')?.scrollIntoView({ block: 'nearest' }))
}
const flatIndex = h => hits.value.indexOf(h)
onMounted(() => {
addEventListener('keydown', onKey)
nextTick(() => input.value?.focus())
})
onUnmounted(() => { removeEventListener('keydown', onKey); clearTimeout(timer) })
</script>
<template>
<div class="cp-overlay" @click.self="close">
<section class="cp-panel popover-glass">
<label class="cp-input">
<Search />
<input ref="input" v-model="q" :placeholder="t('searchPlaceholder')" spellcheck="false" />
<kbd>Esc</kbd>
</label>
<div v-if="groups.length" ref="listEl" class="cp-list">
<div v-for="g in groups" :key="g.kind" class="cp-group">
<small>{{ t(kindMeta[g.kind].label) }}</small>
<button v-for="h in g.items" :key="g.kind + h.id" class="cp-item" :class="{ active: flatIndex(h) === sel }"
@mouseenter="sel = flatIndex(h)" @click="go(h)">
<component :is="kindMeta[g.kind].icon" />
<span class="cp-title">{{ h.title || '—' }}</span>
<span v-if="h.sub" class="cp-sub">{{ h.sub }}</span>
<CornerDownLeft v-if="flatIndex(h) === sel" class="cp-enter" />
</button>
</div>
</div>
<p v-else-if="searched && !busy" class="cp-empty">{{ t('searchEmpty') }}</p>
<p v-else class="cp-empty muted">{{ t('searchHint') }}</p>
</section>
</div>
</template>

View File

@@ -0,0 +1,141 @@
<script setup>
// 每日心语:登录状态下每天首次进入自动弹出一次;
// 也可从日历页手动打开store.dailyCardDate支持前后翻页浏览历史卡片。
// 图片按日期作种子picsum seed保证"一天一张固定的随机图",离线时退化为日期渐变底。
import { computed, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { X, Quote, ChevronLeft, ChevronRight } from 'lucide-vue-next'
import { useAppStore } from '../store'
const store = useAppStore()
const { t, locale } = useI18n()
const open = ref(false)
const imgOk = ref(true)
const autoMode = ref(false)
const KEY = 'cc-daily-card'
const QUOTES_ZH = [
'把每一件平凡的事做好,就是不平凡。', '慢慢来,比较快。', '代码如诗,生活如歌。', '今天的努力,是明天的底气。',
'保持热爱,奔赴山海。', '先完成,再完美。', '少即是多,慢即是快。', '你写下的每一行,都在塑造未来的你。',
'别害怕重构人生,版本迭代是常态。', '心之所向,素履以往。', '星光不问赶路人,时光不负有心人。', '简单是可靠的前提。',
'日拱一卒,功不唐捐。', '生活不是等待风暴过去,而是学会在雨中起舞。', '种一棵树最好的时间是十年前,其次是现在。', '路虽远行则将至,事虽难做则必成。',
'允许一切发生,然后继续前行。', '热爱可抵岁月漫长。', '所有的惊艳,都来自长久的努力。', '不乱于心,不困于情。',
'你若盛开,清风自来。', '万事尽头,终将如意。', '愿你眼中有光,心中有梦。', '认真生活的人,运气不会太差。'
]
const QUOTES_EN = [
'Make each day your masterpiece.', 'Slow is smooth, smooth is fast.', 'Code is poetry; life is music.', "Today's effort is tomorrow's confidence.",
'Stay hungry, stay foolish.', 'Done is better than perfect.', 'Less is more.', 'Every line you write shapes who you become.',
'Refactor your life; iteration is normal.', 'Where there is a will, there is a way.', "The stars don't ask the traveler why.", 'Simplicity is a prerequisite for reliability.',
'Small steps every day add up to big results.', "Life isn't about waiting for the storm to pass, it's about dancing in the rain.", 'The best time to plant a tree was ten years ago; the second best is now.', 'A long road tests a willing heart.',
'Let it be, and keep going.', 'Passion outlasts the years.', 'Great things take time.', 'Calm mind, steady hands.',
'Bloom, and the breeze will come.', 'All will be well in the end.', 'May your eyes hold light and your heart hold dreams.', 'Live earnestly and luck will follow.'
]
const pad = n => String(n).padStart(2, '0')
const fmt = d => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
const todayStr = fmt(new Date())
const cardDate = ref(new Date())
const dateStr = computed(() => fmt(cardDate.value))
const dayIndex = computed(() => Math.floor(Date.UTC(cardDate.value.getFullYear(), cardDate.value.getMonth(), cardDate.value.getDate()) / 86400000))
const zh = computed(() => locale.value === 'zh-CN')
const dateTitle = computed(() => zh.value
? `${cardDate.value.getFullYear()}${cardDate.value.getMonth() + 1}${cardDate.value.getDate()}`
: cardDate.value.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }))
const weekday = computed(() => cardDate.value.toLocaleDateString(zh.value ? 'zh-CN' : 'en-US', { weekday: 'long' }))
const quote = computed(() => (zh.value ? QUOTES_ZH : QUOTES_EN)[((dayIndex.value % QUOTES_ZH.length) + QUOTES_ZH.length) % QUOTES_ZH.length])
const imgUrl = computed(() => `https://picsum.photos/seed/cc-${dateStr.value}/840/560`)
const fallbackStyle = computed(() => {
const hue = ((dayIndex.value * 47) % 360 + 360) % 360
return { background: `linear-gradient(135deg, hsl(${hue} 62% 36%), hsl(${(hue + 70) % 360} 55% 24%))` }
})
// 黄历:农历、干支、节日/节气、宜忌lunar 库较大,打开时才动态加载,避免拖慢启动)
const almanac = ref(null)
let SolarMod = null
async function loadAlmanac() {
try {
if (!SolarMod) SolarMod = (await import('lunar-javascript')).Solar
const solar = SolarMod.fromDate(cardDate.value)
const lunar = solar.getLunar()
almanac.value = {
lunarDate: `${lunar.getMonthInChinese()}${lunar.getDayInChinese()}`,
ganzhi: `${lunar.getYearInGanZhi()}${lunar.getYearShengXiao()}`,
yi: lunar.getDayYi().slice(0, 4),
ji: lunar.getDayJi().slice(0, 4),
fests: [...lunar.getFestivals(), ...solar.getFestivals(), ...(lunar.getJieQi() ? [lunar.getJieQi()] : [])]
}
} catch {
almanac.value = { lunarDate: '', ganzhi: '', yi: [], ji: [], fests: [] }
}
}
const canNext = computed(() => dateStr.value < todayStr)
function nav(delta) {
if (delta > 0 && !canNext.value) return
const d = new Date(cardDate.value)
d.setDate(d.getDate() + delta)
cardDate.value = d
imgOk.value = true
loadAlmanac()
}
function close() {
if (autoMode.value) localStorage.setItem(KEY, todayStr)
open.value = false
store.dailyCardDate = ''
}
function openFor(d, auto) {
autoMode.value = auto
cardDate.value = d
imgOk.value = true
loadAlmanac()
open.value = true
}
function maybeOpen() {
if (!store.syncStatus.loggedIn || open.value) return
if (localStorage.getItem(KEY) === todayStr) return
setTimeout(() => { if (!open.value) openFor(new Date(), true) }, 700)
}
onMounted(maybeOpen)
watch(() => store.syncStatus.loggedIn, v => { if (v) maybeOpen() })
// 日历页入口:设置 store.dailyCardDate = 'YYYY-MM-DD' 打开对应日期的卡片(不超过今天)
watch(() => store.dailyCardDate, v => {
if (!v) return
const d = new Date(`${v}T00:00:00`)
if (isNaN(d) || fmt(d) > todayStr) return
openFor(d, false)
})
</script>
<template>
<Teleport to="body">
<div v-if="open && almanac" class="overlay daily-overlay" @click.self="close">
<section class="daily-card" role="dialog" aria-modal="true">
<div class="daily-hero" :style="fallbackStyle">
<img v-if="imgOk" :key="dateStr" :src="imgUrl" alt="" @error="imgOk = false" />
<div class="daily-mask" />
<button class="daily-close" :aria-label="t('close')" @click="close"><X /></button>
<button class="daily-nav prev" :title="t('dailyPrevDay')" @click.stop="nav(-1)"><ChevronLeft /></button>
<button class="daily-nav next" :disabled="!canNext" :title="t('dailyNextDay')" @click.stop="nav(1)"><ChevronRight /></button>
<div class="daily-head">
<span class="daily-kicker">{{ t('dailyCard') }}<em v-if="dateStr !== todayStr" class="daily-history-tag">{{ t('dailyHistory') }}</em></span>
<b class="daily-date">{{ dateTitle }}</b>
<span class="daily-sub">{{ weekday }} · {{ almanac.ganzhi }} {{ almanac.lunarDate }}</span>
<span v-if="almanac.fests.length" class="daily-fest">{{ almanac.fests.join(' · ') }}</span>
</div>
</div>
<div class="daily-body">
<p class="daily-quote"><Quote />{{ quote }}</p>
<div class="daily-almanac">
<div class="daily-yi"><i>{{ t('dailyYi') }}</i><span v-for="x in almanac.yi" :key="x">{{ x }}</span></div>
<div class="daily-ji"><i>{{ t('dailyJi') }}</i><span v-for="x in almanac.ji" :key="x">{{ x }}</span></div>
</div>
<button class="btn primary daily-ok" @click="close">{{ t('dailyGotIt') }}</button>
</div>
</section>
</div>
</Teleport>
</template>

View File

@@ -0,0 +1,125 @@
<script setup>
// DatePicker 全工程统一的日期选择器v-model 传 'YYYY-MM-DD'(空串表示未选)。
// 输入框支持手动输入灵活格式20260501 / 0501 / 05-01 等,见 dateutil.js
// 弹出面板支持 日/月/年 三级视图切换;替代原生 input[type=date](样式不统一且不好用)。
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { CalendarDays, ChevronLeft, ChevronRight, X } from 'lucide-vue-next'
import { parseFlexDate, toYmd, pad2 } from '../dateutil'
const props = defineProps({
modelValue: { type: String, default: '' },
placeholder: { type: String, default: '' },
disabled: { type: Boolean, default: false },
clearable: { type: Boolean, default: true }
})
const emit = defineEmits(['update:modelValue'])
const { t, locale } = useI18n()
const open = ref(false)
const view = ref('day') // day | month | year
const typed = ref(props.modelValue)
const root = ref(null)
// 面板游标:打开时定位到已选日期或今天
const cur = ref(startDate())
function startDate() {
const p = parseFlexDate(props.modelValue)
return p ? new Date(Number(p.slice(0, 4)), Number(p.slice(5, 7)) - 1, 1) : new Date()
}
watch(() => props.modelValue, v => { typed.value = v })
const zh = computed(() => locale.value === 'zh-CN')
const weekdays = computed(() => zh.value ? ['日', '一', '二', '三', '四', '五', '六'] : ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'])
const monthNames = computed(() => zh.value
? ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月']
: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'])
const yearLabel = computed(() => zh.value ? `${cur.value.getFullYear()}` : String(cur.value.getFullYear()))
const monthLabel = computed(() => monthNames.value[cur.value.getMonth()])
const todayStr = toYmd(new Date())
const grid = computed(() => {
const first = new Date(cur.value.getFullYear(), cur.value.getMonth(), 1)
const start = new Date(first)
start.setDate(1 - first.getDay())
const cells = []
for (let i = 0; i < 42; i++) {
const d = new Date(start)
d.setDate(start.getDate() + i)
const v = toYmd(d)
cells.push({ v, day: d.getDate(), inMonth: d.getMonth() === cur.value.getMonth(), today: v === todayStr, active: v === props.modelValue })
}
return cells
})
// 年视图:以游标年为中心的 12 年窗口
const yearBase = computed(() => Math.floor(cur.value.getFullYear() / 12) * 12)
const years = computed(() => Array.from({ length: 12 }, (_, i) => yearBase.value + i))
function toggle() {
if (props.disabled) return
open.value = !open.value
if (open.value) { view.value = 'day'; cur.value = startDate() }
}
function moveMonth(d) { cur.value = new Date(cur.value.getFullYear(), cur.value.getMonth() + d, 1) }
function nav(d) {
if (view.value === 'day') moveMonth(d)
else if (view.value === 'month') cur.value = new Date(cur.value.getFullYear() + d, cur.value.getMonth(), 1)
else cur.value = new Date(cur.value.getFullYear() + d * 12, cur.value.getMonth(), 1)
}
function pickDay(c) { emit('update:modelValue', c.v); open.value = false }
function pickMonth(i) { cur.value = new Date(cur.value.getFullYear(), i, 1); view.value = 'day' }
function pickYear(y) { cur.value = new Date(y, cur.value.getMonth(), 1); view.value = 'month' }
function pickToday() { emit('update:modelValue', todayStr); open.value = false }
function clearVal() { emit('update:modelValue', ''); typed.value = ''; open.value = false }
// 手动输入:回车/失焦时解析(默认年份取面板游标年),非法输入回退当前值
function commitTyped() {
const raw = typed.value.trim()
if (raw === '') { if (props.modelValue) emit('update:modelValue', ''); return }
const p = parseFlexDate(raw, cur.value.getFullYear())
if (p) {
emit('update:modelValue', p)
cur.value = new Date(Number(p.slice(0, 4)), Number(p.slice(5, 7)) - 1, 1)
} else typed.value = props.modelValue
}
function onDocClick(e) { if (root.value && !root.value.contains(e.target)) open.value = false }
onMounted(() => addEventListener('mousedown', onDocClick))
onUnmounted(() => removeEventListener('mousedown', onDocClick))
</script>
<template>
<div ref="root" class="dp" :class="{ disabled }">
<div class="dp-box" @click="toggle">
<CalendarDays class="dp-ico" />
<input v-model="typed" class="dp-input" :placeholder="placeholder || (zh ? '如 0501 / 2026-05-01' : 'e.g. 0501 / 2026-05-01')"
:disabled="disabled" @click.stop="open = true" @keydown.enter.prevent="commitTyped" @blur="commitTyped" />
<button v-if="clearable && modelValue && !disabled" type="button" class="dp-clear" :title="t('dpClear')" @click.stop="clearVal"><X /></button>
</div>
<div v-if="open" class="dp-panel popover-glass" @mousedown.stop>
<header class="dp-head">
<button type="button" class="dp-nav" @click="nav(-1)"><ChevronLeft /></button>
<div class="dp-ym">
<button type="button" :class="{ on: view === 'year' }" @click="view = view === 'year' ? 'day' : 'year'">{{ view === 'year' ? `${years[0]} - ${years[11]}` : yearLabel }}</button>
<button v-if="view !== 'year'" type="button" :class="{ on: view === 'month' }" @click="view = view === 'month' ? 'day' : 'month'">{{ monthLabel }}</button>
</div>
<button type="button" class="dp-nav" @click="nav(1)"><ChevronRight /></button>
</header>
<template v-if="view === 'day'">
<div class="dp-week"><span v-for="w in weekdays" :key="w">{{ w }}</span></div>
<div class="dp-grid">
<button v-for="c in grid" :key="c.v" type="button" class="dp-day"
:class="{ dim: !c.inMonth, today: c.today, active: c.active }" @click="pickDay(c)">{{ c.day }}</button>
</div>
</template>
<div v-else-if="view === 'month'" class="dp-grid months">
<button v-for="(m, i) in monthNames" :key="m" type="button" class="dp-cell" :class="{ active: i === cur.getMonth() }" @click="pickMonth(i)">{{ m }}</button>
</div>
<div v-else class="dp-grid months">
<button v-for="y in years" :key="y" type="button" class="dp-cell" :class="{ active: y === cur.getFullYear() }" @click="pickYear(y)">{{ y }}</button>
</div>
<footer class="dp-foot">
<button type="button" @click="pickToday">{{ t('today') }}</button>
<button v-if="clearable" type="button" @click="clearVal">{{ t('dpClear') }}</button>
</footer>
</div>
</div>
</template>

View File

@@ -0,0 +1,74 @@
<script setup>
// 截止日期快捷标签:今天 / 明天 / N 天后。标签可自定义添加、可删除,列表存 localStorage 全局共享。
import { nextTick, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { Plus, X } from 'lucide-vue-next'
const props = defineProps({
modelValue: { type: String, default: '' },
withTime: { type: Boolean, default: false },
disabled: { type: Boolean, default: false }
})
const emit = defineEmits(['update:modelValue'])
const { t } = useI18n()
const KEY = 'cc-due-quick'
function load() {
try {
const v = JSON.parse(localStorage.getItem(KEY))
if (Array.isArray(v) && v.every(n => Number.isInteger(n) && n >= 0)) return v
} catch {}
return [0, 1, 3, 7]
}
const days = ref(load())
const adding = ref(false)
const addVal = ref('')
const addInput = ref(null)
const save = () => localStorage.setItem(KEY, JSON.stringify(days.value))
const ymdAfter = n => {
const d = new Date()
d.setDate(d.getDate() + n)
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
const label = n => n === 0 ? t('dqToday') : n === 1 ? t('dqTomorrow') : n === 2 ? t('dqDayAfter') : t('dqDays', { n })
const isActive = n => (props.modelValue || '').slice(0, 10) === ymdAfter(n)
function pick(n) {
if (props.disabled) return
emit('update:modelValue', props.withTime ? `${ymdAfter(n)}T18:00` : ymdAfter(n))
}
function remove(n) {
days.value = days.value.filter(x => x !== n)
save()
}
function openAdd() {
if (props.disabled) return
adding.value = true
addVal.value = ''
nextTick(() => addInput.value?.focus())
}
function confirmAdd() {
const n = parseInt(addVal.value, 10)
adding.value = false
if (!Number.isInteger(n) || n < 0 || n > 365) return
if (!days.value.includes(n)) {
days.value = [...days.value, n].sort((a, b) => a - b)
save()
}
}
</script>
<template>
<div class="due-quick">
<button v-for="n in days" :key="n" type="button" class="dq-chip" :class="{ active: isActive(n) }" :disabled="disabled" @click="pick(n)">
{{ label(n) }}
<i class="dq-x" :title="t('dqRemove')" @click.stop="remove(n)"><X /></i>
</button>
<button v-if="!adding" type="button" class="dq-chip dq-add" :title="t('dqAddTip')" :disabled="disabled" @click="openAdd"><Plus /></button>
<span v-else class="dq-chip dq-input">
<input ref="addInput" v-model="addVal" type="number" min="0" max="365" @keyup.enter="confirmAdd" @keyup.esc="adding = false" @blur="confirmAdd" />
{{ t('dqSuffix') }}
</span>
</div>
</template>

View File

@@ -0,0 +1,123 @@
<script setup>
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { X, FileCode2, Copy, Binary, RefreshCw } from 'lucide-vue-next'
import hljs from 'highlight.js/lib/common'
import 'highlight.js/styles/github-dark.css'
import { call } from '../api'
import { useAppStore } from '../store'
const props = defineProps({ projectId: { type: Number, required: true }, path: { type: String, required: true }, line: { type: Number, default: 0 } })
const emit = defineEmits(['close'])
const { t } = useI18n()
const store = useAppStore()
const file = ref(null)
const error = ref('')
const loading = ref(true)
const bodyEl = ref(null)
const hl = ref({ top: -1, height: 21 })
const extLang = {
'.js': 'javascript', '.mjs': 'javascript', '.cjs': 'javascript', '.jsx': 'javascript',
'.ts': 'typescript', '.tsx': 'typescript', '.vue': 'xml', '.html': 'xml', '.xml': 'xml', '.svg': 'xml',
'.css': 'css', '.scss': 'scss', '.less': 'less', '.go': 'go', '.py': 'python', '.rb': 'ruby',
'.php': 'php', '.java': 'java', '.kt': 'kotlin', '.rs': 'rust', '.c': 'c', '.h': 'c',
'.cpp': 'cpp', '.hpp': 'cpp', '.cs': 'csharp', '.sh': 'bash', '.ps1': 'powershell',
'.sql': 'sql', '.json': 'json', '.yml': 'yaml', '.yaml': 'yaml', '.toml': 'ini', '.ini': 'ini',
'.md': 'markdown', '.dockerfile': 'dockerfile'
}
const lineCount = computed(() => (file.value?.content ? file.value.content.split('\n').length : 0))
const highlighted = computed(() => {
const src = file.value?.content ?? ''
if (!src || src.length > 300000) return ''
const lang = extLang[file.value?.extension || '']
try {
const out = lang && hljs.getLanguage(lang) ? hljs.highlight(src, { language: lang }) : hljs.highlightAuto(src)
return out.value
} catch {
return ''
}
})
const sizeText = computed(() => {
const n = file.value?.size || 0
return n >= 1048576 ? (n / 1048576).toFixed(1) + ' MB' : n >= 1024 ? (n / 1024).toFixed(1) + ' KB' : n + ' B'
})
async function load() {
loading.value = true
error.value = ''
hl.value = { top: -1, height: 21 }
try {
file.value = await call('ReadProjectFile', props.projectId, props.path)
} catch (e) {
const code = String(e).split(':')[0]
error.value = ['FILE_NOT_FOUND', 'FILE_IS_DIRECTORY', 'FILE_PATH_INVALID', 'FILE_READ_FAILED'].includes(code) ? t(`errors.${code}`) : String(e)
} finally {
loading.value = false
}
locate()
}
// 定位到指定行:滚动至视口上 1/3 处并放置高亮条
async function locate() {
if (!props.line || props.line > lineCount.value) return
await nextTick()
const el = bodyEl.value
const span = el?.querySelector(`.preview-gutter span:nth-child(${props.line})`)
if (!span) return
hl.value = { top: span.offsetTop, height: span.offsetHeight || 21 }
el.scrollTop = Math.max(0, span.offsetTop - el.clientHeight * 0.33)
}
async function copy() {
try {
await navigator.clipboard.writeText(file.value?.content || '')
store.showToast({ type: 'success', key: 'copied' })
} catch {
store.showToast({ type: 'error', text: t('copyFailed') })
}
}
function onKey(e) {
if (e.key === 'Escape') emit('close')
}
watch(() => props.path, load)
onMounted(() => {
load()
addEventListener('keydown', onKey)
})
onUnmounted(() => removeEventListener('keydown', onKey))
</script>
<template>
<Teleport to="body">
<div class="overlay preview-overlay" @click.self="emit('close')">
<section class="modal preview-modal">
<header>
<div class="preview-title">
<FileCode2 />
<div>
<h2>{{ file?.name || path.split('/').pop() }}</h2>
<p :title="path">{{ path }}</p>
</div>
</div>
<div class="preview-meta" v-if="file && !file.binary">
<span>{{ sizeText }}</span>
<span>{{ file.lines }} {{ t('lines') }}</span>
<span v-if="file.truncated" class="preview-truncated">{{ t('previewTruncated') }}</span>
</div>
<div class="preview-actions">
<button v-if="file && !file.binary" :title="t('copyContent')" @click="copy"><Copy /></button>
<button :title="t('cancel')" @click="emit('close')"><X /></button>
</div>
</header>
<div v-if="loading" class="preview-state"><RefreshCw class="spin" />{{ t('previewLoading') }}</div>
<div v-else-if="error" class="preview-state error">{{ error }}</div>
<div v-else-if="file?.binary" class="preview-state"><Binary />{{ t('previewBinary') }} · {{ sizeText }}</div>
<div v-else ref="bodyEl" class="preview-body">
<i v-if="hl.top >= 0" class="preview-hl-line" :style="{ top: hl.top + 'px', height: hl.height + 'px' }" aria-hidden="true" />
<div class="preview-gutter" aria-hidden="true"><span v-for="i in lineCount" :key="i" :class="{ hit: i === line }">{{ i }}</span></div>
<pre class="preview-src"><code v-if="highlighted" class="hljs" v-html="highlighted"></code><code v-else>{{ file?.content }}</code></pre>
</div>
</section>
</div>
</Teleport>
</template>

View File

@@ -0,0 +1,96 @@
<script setup>
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { History, Plus, Circle, CircleDot, CircleCheck, Play, Check, Archive, Flag } from 'lucide-vue-next'
// 生命周期时间线:解析 history JSON[{status,at}]),从创建到完结逐节点展示。
const props = defineProps({
history: { type: String, default: '' },
createdAt: { type: String, default: '' },
updatedAt: { type: String, default: '' },
status: { type: String, default: 'open' },
kind: { type: String, default: 'todo' } // todo | ticket
})
const { t } = useI18n()
const doneStatuses = { todo: ['done'], ticket: ['resolved', 'closed'] }
const icons = {
todo: { open: Circle, doing: CircleDot, done: CircleCheck },
ticket: { open: Circle, in_progress: Play, resolved: Check, closed: Archive }
}
const raw = computed(() => {
let list = []
try { list = JSON.parse(props.history || '[]') || [] } catch { list = [] }
list = list.filter(n => n && n.at)
// 旧数据兜底:没有轨迹时至少给出创建节点;当前状态与最后节点不一致时用 updatedAt 近似补齐。
if (!list.length && props.createdAt) list = [{ status: 'open', at: props.createdAt }]
if (list.length && list[list.length - 1].status !== props.status && props.updatedAt) {
list = [...list, { status: props.status, at: props.updatedAt }]
}
return list
})
const fmtTs = at => {
const d = new Date(at)
if (isNaN(d)) return at
const p = n => String(n).padStart(2, '0')
const ymd = `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`
return `${ymd} ${p(d.getHours())}:${p(d.getMinutes())}`
}
const fmtDur = ms => {
const min = Math.floor(ms / 60000)
if (min < 1) return t('durMoment')
if (min < 60) return t('durMin', { n: min })
const h = Math.floor(min / 60)
if (h < 24) return min % 60 ? `${t('durHour', { n: h })} ${t('durMin', { n: min % 60 })}` : t('durHour', { n: h })
const d = Math.floor(h / 24)
return h % 24 ? `${t('durDay', { n: d })} ${t('durHour', { n: h % 24 })}` : t('durDay', { n: d })
}
const nodes = computed(() => raw.value.map((n, i) => {
const prev = i > 0 ? new Date(raw.value[i - 1].at) : null
const cur = new Date(n.at)
return {
key: i + n.status,
status: n.status,
icon: i === 0 ? Plus : (icons[props.kind][n.status] || Circle),
label: i === 0 ? t('lifecycleCreated') : t((props.kind === 'ticket' ? 'ticketStatus.' : 'todoStatus.') + n.status),
sub: i === 0 && n.status !== 'open' ? t((props.kind === 'ticket' ? 'ticketStatus.' : 'todoStatus.') + n.status) : '',
time: fmtTs(n.at),
gap: prev && !isNaN(prev) && !isNaN(cur) && cur - prev >= 0 ? fmtDur(cur - prev) : '',
done: i > 0 && doneStatuses[props.kind].includes(n.status)
}
}))
const finished = computed(() => raw.value.length > 0 && doneStatuses[props.kind].includes(raw.value[raw.value.length - 1].status))
const total = computed(() => {
if (!finished.value || raw.value.length < 2) return ''
const a = new Date(raw.value[0].at), b = new Date(raw.value[raw.value.length - 1].at)
return isNaN(a) || isNaN(b) || b - a < 0 ? '' : fmtDur(b - a)
})
</script>
<template>
<div v-if="nodes.length" class="lifecycle">
<div class="lc-head"><History />{{ t('lifecycle') }}</div>
<div class="lc-list">
<div v-for="n in nodes" :key="n.key" class="lc-node" :class="[kind + '-' + n.status, { done: n.done }]">
<span class="lc-dot"><component :is="n.icon" /></span>
<div class="lc-body">
<div class="lc-row">
<b>{{ n.label }}</b>
<i v-if="n.sub" class="lc-sub">{{ n.sub }}</i>
<span v-if="n.gap" class="lc-gap">+{{ n.gap }}</span>
</div>
<span class="lc-time">{{ n.time }}</span>
</div>
</div>
<div v-if="!finished" class="lc-node running">
<span class="lc-dot pulse"><Flag /></span>
<div class="lc-body"><div class="lc-row"><b>{{ t('lifecycleNow') }}</b></div></div>
</div>
</div>
<p v-if="total" class="lc-total"><CircleCheck />{{ t('lifecycleTotal', { dur: total }) }}</p>
</div>
</template>

View File

@@ -0,0 +1,117 @@
<script setup>
import { reactive, ref, onMounted, onUnmounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { X, ShieldCheck, Eye, EyeOff, ArrowRight } from 'lucide-vue-next'
import { call, isNative } from '../api'
import { useAppStore } from '../store'
const store = useAppStore()
const { t } = useI18n()
const native = isNative()
const mode = ref('login') // login | register
const form = reactive({ username: '', password: '', confirm: '' })
const busy = ref(false)
const msg = ref('')
const showPwd = ref(false)
const userInput = ref(null)
function errText(e) {
const code = String(e?.message || e).trim()
return t('errors.' + code) !== 'errors.' + code ? t('errors.' + code) : String(e)
}
function switchMode(m) {
mode.value = m
msg.value = ''
}
async function submit() {
if (busy.value || !form.username || !form.password) return
msg.value = ''
if (mode.value === 'register' && form.password !== form.confirm) {
msg.value = t('passwordMismatch')
return
}
busy.value = true
try {
if (mode.value === 'register') await call('SyncRegister', form.username, form.password)
await call('SyncLogin', form.username, form.password)
await store.refreshSyncStatus()
store.showToast({ type: 'success', key: mode.value === 'register' ? 'registerOkToast' : 'loginOkToast' })
form.password = form.confirm = ''
store.loginOpen = false
} catch (e) {
msg.value = errText(e)
} finally {
busy.value = false
}
}
function onKey(e) {
if (e.key === 'Escape') store.loginOpen = false
}
watch(mode, () => userInput.value?.focus())
onMounted(() => {
addEventListener('keydown', onKey)
userInput.value?.focus()
})
onUnmounted(() => removeEventListener('keydown', onKey))
</script>
<template>
<div class="login-overlay" @click.self="store.loginOpen = false">
<section class="login-card">
<button class="login-close" :aria-label="t('close')" @click="store.loginOpen = false"><X /></button>
<aside class="lg-art" aria-hidden="true">
<i class="lg-rings" />
<span class="lg-logo">
<svg viewBox="0 0 48 48" role="img">
<path d="M17 18l-6 6 6 6M31 18l6 6-6 6M27 14l-6 20" fill="none" stroke="currentColor" stroke-width="3.2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
<b>年糕崽崽 PMS</b>
</span>
<div class="lg-copy">
<h3>{{ t('loginTagline') }}</h3>
<p>{{ t('loginModalHint') }}</p>
</div>
<span class="lg-badge"><ShieldCheck />{{ t('loginSecureBadge') }}</span>
</aside>
<div class="lg-main">
<nav class="lg-tabs">
<button :class="{ active: mode === 'login' }" @click="switchMode('login')">{{ t('loginBtn') }}</button>
<button :class="{ active: mode === 'register' }" @click="switchMode('register')">{{ t('registerBtn') }}</button>
</nav>
<h2 class="lg-title">{{ mode === 'login' ? t('loginWelcome') : t('registerWelcome') }}</h2>
<div class="lg-fields">
<label class="lg-field">
<span>{{ t('fieldUser') }}</span>
<input ref="userInput" v-model.trim="form.username" :placeholder="t('usernamePh')" autocomplete="username" @keyup.enter="submit" />
</label>
<label class="lg-field lg-pass">
<span>{{ t('fieldPass') }}</span>
<input v-model="form.password" :type="showPwd ? 'text' : 'password'" :placeholder="t('passwordPh')" :autocomplete="mode === 'register' ? 'new-password' : 'current-password'" @keyup.enter="submit" />
<button type="button" class="login-eye" :aria-label="showPwd ? t('hidePassword') : t('showPassword')" @click="showPwd = !showPwd"><EyeOff v-if="showPwd" /><Eye v-else /></button>
</label>
<Transition name="login-field">
<label v-if="mode === 'register'" class="lg-field">
<span>{{ t('fieldConfirm') }}</span>
<input v-model="form.confirm" :type="showPwd ? 'text' : 'password'" :placeholder="t('confirmPh')" autocomplete="new-password" @keyup.enter="submit" />
</label>
</Transition>
<Transition name="login-field">
<p v-if="msg" class="lg-error">{{ msg }}</p>
</Transition>
<button class="lg-submit" :disabled="!native || busy || !form.username || !form.password" @click="submit">
{{ busy ? (mode === 'register' ? t('registering') : t('loggingIn')) : (mode === 'register' ? t('registerAndLogin') : t('loginBtn')) }}
<ArrowRight v-if="!busy" />
</button>
<button class="login-switch" @click="switchMode(mode === 'login' ? 'register' : 'login')">
{{ mode === 'login' ? t('switchToRegister') : t('switchToLogin') }}
</button>
</div>
<p class="lg-foot"><ShieldCheck />{{ t('syncLoginHint') }}</p>
</div>
</section>
</div>
</template>

View File

@@ -0,0 +1,59 @@
<script setup>
// MarkdownView待办/工单内容的 Markdown 渲染。
// 本地路径图片path 存储模式)经后端读成 dataURL 再显示;外部链接交给系统浏览器打开。
import { nextTick, ref, watch } from 'vue'
import { marked } from 'marked'
import { Browser } from '@wailsio/runtime'
import { call, isNative } from '../api'
const props = defineProps({ source: { type: String, default: '' } })
const el = ref(null)
const html = ref('')
// 各实例共享本地图片缓存,同一张图只读一次。
const imgCache = new Map()
watch(() => props.source, render, { immediate: true })
async function render() {
html.value = marked.parse(props.source || '', { breaks: true, gfm: true, async: false })
await nextTick()
hydrateImages()
}
function hydrateImages() {
if (!el.value) return
for (const img of el.value.querySelectorAll('img')) {
const src = img.getAttribute('src') || ''
if (!src || /^(data:|https?:)/i.test(src)) continue
img.classList.add('md-img-loading')
resolveLocal(src).then(dataURL => {
if (dataURL) { img.src = dataURL; img.classList.remove('md-img-loading') }
else img.classList.replace('md-img-loading', 'md-img-broken')
})
}
}
async function resolveLocal(path) {
if (imgCache.has(path)) return imgCache.get(path)
let dataURL = ''
if (isNative()) {
try { dataURL = await call('ReadContentImageAsDataURL', path) } catch { dataURL = '' }
}
imgCache.set(path, dataURL)
return dataURL
}
function onClick(e) {
const a = e.target.closest('a')
if (!a) return
e.preventDefault()
e.stopPropagation()
const href = a.getAttribute('href') || ''
if (/^https?:/i.test(href)) Browser.OpenURL(href)
}
</script>
<template>
<div ref="el" class="md-content" v-html="html" @click="onClick" />
</template>

View File

@@ -0,0 +1,79 @@
<script setup>
import { onMounted, onUnmounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { Bell, CheckCheck, Trash2, ListTodo, TicketCheck, BarChart3, RefreshCw, Info, ArrowRight } from 'lucide-vue-next'
import { call } from '../api'
import { useAppStore } from '../store'
const router = useRouter()
const store = useAppStore()
const { t } = useI18n()
const open = ref(false)
const items = ref([])
const wrap = ref(null)
const kindIcon = { todo_due: ListTodo, ticket_due: TicketCheck, analysis: BarChart3, sync: RefreshCw }
async function toggle() {
open.value = !open.value
if (open.value) items.value = await call('ListMessages', 50)
}
async function markRead(m) {
if (!m.read) {
await call('MarkMessageRead', m.id)
m.read = true
store.refreshUnread()
}
if (m.sourceType === 'todo') router.push('/todos')
else if (m.sourceType === 'ticket') router.push('/tickets')
open.value = false
}
async function markAll() {
await call('MarkAllMessagesRead')
items.value.forEach(m => { m.read = true })
store.refreshUnread()
}
async function clearAll() {
if (!confirm(t('clearMessages') + '?')) return
await call('ClearMessages')
items.value = []
store.refreshUnread()
}
function onClickAway(e) {
if (wrap.value && !wrap.value.contains(e.target)) open.value = false
}
onMounted(() => addEventListener('click', onClickAway))
onUnmounted(() => removeEventListener('click', onClickAway))
</script>
<template>
<div ref="wrap" class="bell-wrap">
<button class="bell-btn" :title="t('messages')" @click="toggle">
<Bell />
<i v-if="store.unreadMessages" class="bell-badge">{{ store.unreadMessages > 99 ? '99+' : store.unreadMessages }}</i>
</button>
<div v-if="open" class="bell-dropdown popover-glass">
<header>
<b>{{ t('messages') }}</b>
<div class="bell-tools">
<button :title="t('markAllRead')" @click="markAll"><CheckCheck /></button>
<button :title="t('clearMessages')" @click="clearAll"><Trash2 /></button>
</div>
</header>
<div class="bell-list">
<button v-for="m in items" :key="m.id" class="bell-item" :class="{ unread: !m.read }" @click="markRead(m)">
<span class="bell-icon" :class="m.kind"><component :is="kindIcon[m.kind] || Info" /></span>
<div>
<b>{{ m.title }}</b>
<p v-if="m.body">{{ m.body }}</p>
<time>{{ m.createdAt?.replace('T', ' ').slice(0, 16) }}</time>
</div>
<i v-if="!m.read" class="bell-dot" />
</button>
<div v-if="!items.length" class="bell-empty">{{ t('noMessages') }}</div>
</div>
<button type="button" class="bell-more" @click="open = false; router.push('/messages')">{{ t('viewAll') }}<ArrowRight /></button>
</div>
</div>
</template>

View File

@@ -0,0 +1,74 @@
<script setup>
import { onMounted, onUnmounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { StickyNote, Plus, ArrowRight } from 'lucide-vue-next'
import { call } from '../api'
import NoteModal from './NoteModal.vue'
// 笔记中心:任意页面查看最近笔记、快速新建与编辑。
const router = useRouter()
const { t } = useI18n()
const open = ref(false)
const wrap = ref(null)
const notes = ref([])
const editing = ref(null) // null=关闭;{} = 新建;{id,...} = 编辑
async function load() {
try { notes.value = await call('ListNotes', 5) } catch { /* 后端未就绪时静默 */ }
}
async function toggle() {
open.value = !open.value
if (open.value) await load()
}
function openNote(n) {
open.value = false
editing.value = n || {}
}
const title = n => {
const line = (n.content || '').split('\n').find(l => l.trim()) || ''
return line.trim().replace(/^#+\s*/, '') || t('noteUntitled')
}
const fullTime = s => String(s || '').replace('T', ' ').slice(0, 19)
function relTime(s) {
const ms = Date.now() - new Date(s).getTime()
if (!isFinite(ms) || ms < 0) return ''
const m = Math.floor(ms / 60000)
if (m < 1) return t('justNow')
if (m < 60) return t('minAgo', { n: m })
const h = Math.floor(m / 60)
if (h < 24) return t('hourAgo', { n: h })
const d = Math.floor(h / 24)
if (d < 7) return t('dayAgo', { n: d })
const local = new Date(s)
return isFinite(local) ? `${local.getMonth() + 1}-${String(local.getDate()).padStart(2, '0')}` : ''
}
function onClickAway(e) {
if (wrap.value && !wrap.value.contains(e.target)) open.value = false
}
onMounted(() => { addEventListener('click', onClickAway); load() })
onUnmounted(() => removeEventListener('click', onClickAway))
</script>
<template>
<div ref="wrap" class="bell-wrap">
<button class="bell-btn" :title="t('noteCenter')" @click="toggle">
<StickyNote />
</button>
<div v-if="open" class="bell-dropdown popover-glass nc-dropdown">
<header>
<b>{{ t('noteCenter') }}</b>
<button type="button" class="nc-new" @click="openNote(null)"><Plus />{{ t('noteNew') }}</button>
</header>
<div class="bell-list">
<div v-for="n in notes" :key="n.id" class="nc-item" @click="openNote(n)">
<b>{{ title(n) }}</b>
<time :title="fullTime(n.updatedAt)">{{ relTime(n.updatedAt) }}</time>
</div>
<div v-if="!notes.length" class="bell-empty">{{ t('noteEmpty') }}</div>
</div>
<button type="button" class="bell-more" @click="open = false; router.push('/notes')">{{ t('viewAll') }}<ArrowRight /></button>
</div>
<NoteModal v-if="editing" :note="editing" @close="editing = null" @changed="load" />
</div>
</template>

View File

@@ -0,0 +1,74 @@
<script setup>
import { onMounted, onUnmounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { StickyNote, Trash2, X } from 'lucide-vue-next'
import { call } from '../api'
// 笔记详情模态框打开即编辑600ms 防抖自动保存;关闭时空内容自动清理。
const props = defineProps({ note: { type: Object, default: null } })
const emit = defineEmits(['close', 'changed'])
const { t } = useI18n()
const noteId = ref(props.note?.id || 0)
const text = ref(props.note?.content || '')
const savedAt = ref('')
const area = ref(null)
let timer = 0
let saving = null
function onInput() {
clearTimeout(timer)
timer = setTimeout(flush, 600)
}
async function flush() {
clearTimeout(timer)
const content = text.value
if (!content.trim() && !noteId.value) return
saving = call('SaveNoteByID', noteId.value, content).then(n => {
noteId.value = n.id
savedAt.value = new Date().toTimeString().slice(0, 8)
emit('changed')
}).catch(() => {})
await saving
}
async function close() {
await flush()
// 内容清空的已有笔记视为不再需要,顺手删除
if (noteId.value && !text.value.trim()) {
try { await call('DeleteNote', noteId.value); emit('changed') } catch {}
}
emit('close')
}
async function removeNote() {
if (!noteId.value) { emit('close'); return }
if (!confirm(t('noteDeleteConfirm'))) return
clearTimeout(timer)
try { await call('DeleteNote', noteId.value); emit('changed') } catch {}
emit('close')
}
function onKey(e) {
if (e.key === 'Escape') { e.stopPropagation(); close() }
}
onMounted(() => {
addEventListener('keydown', onKey)
requestAnimationFrame(() => area.value?.focus())
})
onUnmounted(() => { removeEventListener('keydown', onKey); clearTimeout(timer) })
</script>
<template>
<Teleport to="body">
<div class="overlay" @click.self="close">
<section class="modal note-modal">
<header class="nm-head">
<h2><StickyNote />{{ noteId ? t('noteEdit') : t('noteNew') }}</h2>
<small v-if="savedAt" class="nm-saved">{{ t('autoSaved') }} {{ savedAt }}</small>
<div class="nm-tools">
<button v-if="noteId" type="button" class="nm-del" :title="t('delete')" @click="removeNote"><Trash2 /></button>
<button type="button" class="nm-close" :title="t('close')" @click="close"><X /></button>
</div>
</header>
<textarea ref="area" v-model="text" class="nm-area" :placeholder="t('notepadPlaceholder')" @input="onInput" />
</section>
</div>
</Teleport>
</template>

View File

@@ -0,0 +1,225 @@
<script setup>
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { Sparkles, Plus, Trash2, SendHorizonal, Square, Bot, User, PieChart, GitCommitHorizontal, ListTodo, TicketCheck, ExternalLink, X } from 'lucide-vue-next'
import { marked } from 'marked'
import hljs from 'highlight.js/lib/common'
import 'highlight.js/styles/github-dark.css'
import { call, on } from '../api'
import { useAppStore } from '../store'
// 项目内 AI 问答抽屉:不跳页的“二级页面”,左侧为该项目的聊天记录,右侧为聊天区。
const props = defineProps({
projectId: { type: Number, required: true },
projectName: { type: String, default: '' },
ask: { type: String, default: '' } // 打开时自动发送的问题
})
const emit = defineEmits(['close'])
const router = useRouter()
const store = useAppStore()
const { t } = useI18n()
const conversations = ref([])
const activeId = ref(0)
const messages = ref([])
const input = ref('')
const streamingId = ref(0)
const streamText = ref('')
const streamError = ref('')
const listEl = ref(null)
const streamBuf = {}
let offStream = null
const streaming = computed(() => streamingId.value !== 0 && streamingId.value === activeId.value)
const hasKey = computed(() => store.settings.aiProvider === 'deepseek' ? !!store.settings.deepSeekKey : !!store.settings.sparkKey)
marked.setOptions({ breaks: true, gfm: true })
const md = s => marked.parse(s || '')
const quicks = [
{ key: 'project', icon: PieChart, label: 'aiQuickProject', prompt: 'aiPromptProject' },
{ key: 'git', icon: GitCommitHorizontal, label: 'aiQuickGit', prompt: 'aiPromptGit' },
{ key: 'todo', icon: ListTodo, label: 'aiQuickTodo', prompt: 'aiPromptTodo' },
{ key: 'ticket', icon: TicketCheck, label: 'aiQuickTicket', prompt: 'aiPromptTicket' }
]
function errText(e) {
const code = String(e).split(':')[0].trim()
return t('errors.' + code) !== 'errors.' + code ? t('errors.' + code) : String(e)
}
async function loadConversations() {
conversations.value = await call('ListAIConversations', props.projectId)
}
async function select(c) {
activeId.value = c.id
streamError.value = ''
streamText.value = streamBuf[c.id] || ''
messages.value = await call('GetAIMessages', c.id)
scrollBottom()
}
function newChat() {
activeId.value = 0
messages.value = []
streamError.value = ''
streamText.value = ''
}
async function del(c) {
if (!confirm(t('aiDeleteConfirm'))) return
await call('DeleteAIConversation', c.id)
if (activeId.value === c.id) newChat()
await loadConversations()
}
async function send(text, scen) {
const content = (text ?? input.value).trim()
if (!content || streaming.value) return
streamError.value = ''
try {
const conv = await call('SendAIMessage', activeId.value, props.projectId, scen || 'chat', content)
if (!activeId.value) {
activeId.value = conv.id
await loadConversations()
}
messages.value.push({ id: -Date.now(), role: 'user', content })
input.value = ''
streamingId.value = conv.id
streamBuf[conv.id] = ''
streamText.value = ''
scrollBottom()
} catch (e) {
streamError.value = errText(e)
}
}
function quick(q) {
send(t(q.prompt), q.key)
}
async function stop() {
try { await call('StopAIStream', streamingId.value || activeId.value) } catch {}
}
function openFull() {
const q = activeId.value ? `?project=${props.projectId}&conversation=${activeId.value}` : `?project=${props.projectId}`
emit('close')
router.push('/ai' + q)
}
// RFC3339(UTC) 转本地时区的 MM-DD HH:mm
function convTime(at) {
const d = new Date(at)
if (isNaN(d)) return ''
const p = n => String(n).padStart(2, '0')
return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`
}
function scrollBottom() {
nextTick(() => { if (listEl.value) listEl.value.scrollTop = listEl.value.scrollHeight })
}
function highlightAll() {
nextTick(() => {
listEl.value?.querySelectorAll('pre code:not([data-hl])').forEach(el => {
el.dataset.hl = '1'
hljs.highlightElement(el)
})
})
}
function onKey(e) {
if (e.key === 'Escape') emit('close')
}
onMounted(async () => {
addEventListener('keydown', onKey)
await loadConversations()
if (props.ask && hasKey.value) {
// 从提问框进入:新开会话直接发送
newChat()
await send(props.ask)
} else if (conversations.value.length) {
await select(conversations.value[0])
}
offStream = on('ai:stream', e => {
const cid = e.conversationId
if (e.delta) {
streamBuf[cid] = (streamBuf[cid] || '') + e.delta
if (cid === activeId.value) {
streamText.value = streamBuf[cid]
scrollBottom()
}
}
if (e.done) {
if (cid === streamingId.value) streamingId.value = 0
const finalText = streamBuf[cid] || ''
delete streamBuf[cid]
if (cid === activeId.value) {
if (e.error) streamError.value = errText(e.error)
if (finalText) messages.value.push({ id: e.messageId || -Date.now(), role: 'assistant', content: finalText })
streamText.value = ''
highlightAll()
}
loadConversations()
}
})
})
onUnmounted(() => {
removeEventListener('keydown', onKey)
offStream?.()
})
</script>
<template>
<Teleport to="body">
<div class="ai-drawer-overlay" @click.self="emit('close')">
<section class="ai-drawer">
<header class="ai-drawer-head">
<b><Sparkles />{{ t('aiAskDrawer') }}<span v-if="projectName" class="todo-chip">{{ projectName }}</span></b>
<div class="ai-drawer-tools">
<button class="btn secondary" :title="t('aiOpenFull')" @click="openFull"><ExternalLink />{{ t('aiOpenFull') }}</button>
<button class="ai-drawer-close" :aria-label="t('close')" @click="emit('close')"><X /></button>
</div>
</header>
<div class="ai-drawer-body">
<aside class="ai-drawer-convs">
<button class="btn secondary full ai-drawer-new" @click="newChat"><Plus />{{ t('aiNewChat') }}</button>
<div class="ai-conv-list">
<button v-for="c in conversations" :key="c.id" class="ai-conv" :class="{ active: c.id === activeId }" @click="select(c)">
<b>{{ c.title }}</b>
<small>{{ convTime(c.updatedAt) }}</small>
<i class="ai-conv-del" :title="t('delete')" @click.stop="del(c)"><Trash2 /></i>
</button>
<div v-if="!conversations.length" class="empty wb-empty">{{ t('aiNoHistory') }}</div>
</div>
</aside>
<section class="ai-drawer-chat">
<div class="ai-toolbar ai-drawer-quicks">
<div class="ai-quicks">
<button v-for="q in quicks" :key="q.key" class="btn secondary" :disabled="streaming || !hasKey" @click="quick(q)">
<component :is="q.icon" />{{ t(q.label) }}
</button>
</div>
</div>
<div ref="listEl" class="ai-messages">
<div v-if="!messages.length && !streaming" class="ai-welcome">
<Bot />
<p>{{ hasKey ? t('aiWelcome') : t('aiNoKeyHint') }}</p>
</div>
<div v-for="m in messages" :key="m.id" class="ai-msg" :class="m.role">
<span class="ai-avatar"><component :is="m.role === 'user' ? User : Bot" /></span>
<div v-if="m.role === 'assistant'" class="ai-bubble markdown" v-html="md(m.content)" />
<div v-else class="ai-bubble">{{ m.content }}</div>
</div>
<div v-if="streaming" class="ai-msg assistant">
<span class="ai-avatar"><Bot /></span>
<div class="ai-bubble markdown">
<div v-if="streamText" v-html="md(streamText)" />
<span class="ai-typing"><i /><i /><i /></span>
</div>
</div>
<p v-if="streamError" class="ai-error">{{ streamError }}</p>
</div>
<div class="ai-input-row">
<textarea v-model="input" :placeholder="t('aiAskPlaceholder')" rows="2" :disabled="!hasKey"
@keydown.enter.exact.prevent="send()" />
<button v-if="streaming" class="btn secondary ai-send" @click="stop"><Square />{{ t('aiStop') }}</button>
<button v-else class="btn primary ai-send" :disabled="!input.trim() || !hasKey" @click="send()"><SendHorizonal />{{ t('aiSend') }}</button>
</div>
</section>
</div>
</section>
</div>
</Teleport>
</template>

View File

@@ -0,0 +1,105 @@
<script setup>
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { ClipboardList, ListTodo, TicketCheck, Play, Check, Flag, ArrowRight } from 'lucide-vue-next'
import { call, notifyTasksChanged, onTasksChanged } from '../api'
import TaskDetailModal from './TaskDetailModal.vue'
// 任务中心:任意页面查看/流转进行中与待开始的待办、工单。
const route = useRoute()
const router = useRouter()
const { t } = useI18n()
const open = ref(false)
const wrap = ref(null)
const todos = ref([])
const tickets = ref([])
const detail = ref(null) // { kind, item }:点条目直接弹详情模态框处理
let timer = 0
async function load() {
try {
;[todos.value, tickets.value] = await Promise.all([call('ListTodos', 'all', 0), call('ListTickets', 'all', 0)])
} catch { /* 启动早期后端未就绪时静默 */ }
}
const byDue = (a, b) => (a.dueAt || '9999') < (b.dueAt || '9999') ? -1 : 1
const doing = computed(() => [
...todos.value.filter(x => x.status === 'doing').map(x => ({ ...x, kind: 'todo' })),
...tickets.value.filter(x => x.status === 'in_progress').map(x => ({ ...x, kind: 'ticket' }))
].sort(byDue))
const pending = computed(() => [
...todos.value.filter(x => x.status === 'open').map(x => ({ ...x, kind: 'todo' })),
...tickets.value.filter(x => x.status === 'open').map(x => ({ ...x, kind: 'ticket' }))
].sort(byDue))
const badge = computed(() => doing.value.length + pending.value.length)
const overdue = x => x.dueAt && new Date(x.dueAt.length === 10 ? x.dueAt + 'T23:59' : x.dueAt) < new Date()
// 快捷流转:待办 开始→完成;工单 开始→解决
const actions = x => x.kind === 'todo'
? (x.status === 'open' ? [{ s: 'doing', label: t('taskStart'), icon: Play }, { s: 'done', label: t('taskDone'), icon: Check }] : [{ s: 'done', label: t('taskDone'), icon: Check }])
: (x.status === 'open' ? [{ s: 'in_progress', label: t('taskStart'), icon: Play }] : [{ s: 'resolved', label: t('taskResolve'), icon: Check }])
async function act(x, status) {
await call(x.kind === 'todo' ? 'SetTodoStatus' : 'SetTicketStatus', x.id, status)
await load()
notifyTasksChanged()
}
function goto(x) {
open.value = false
detail.value = { kind: x.kind, item: x }
}
async function toggle() {
open.value = !open.value
if (open.value) await load()
}
function onClickAway(e) {
if (wrap.value && !wrap.value.contains(e.target)) open.value = false
}
watch(() => route.path, load)
let offTasks = null
onMounted(() => {
addEventListener('click', onClickAway)
load()
timer = setInterval(load, 30000)
offTasks = onTasksChanged(load)
})
onUnmounted(() => {
removeEventListener('click', onClickAway)
clearInterval(timer)
offTasks?.()
})
</script>
<template>
<div ref="wrap" class="bell-wrap">
<button class="bell-btn" :title="t('taskCenter')" @click="toggle">
<ClipboardList />
<i v-if="badge" class="bell-badge tc-badge">{{ badge > 99 ? '99+' : badge }}</i>
</button>
<div v-if="open" class="bell-dropdown popover-glass tc-dropdown">
<header><b>{{ t('taskCenter') }}</b></header>
<div class="bell-list">
<template v-for="grp in [{ key: 'doing', label: t('taskDoingGroup'), items: doing }, { key: 'pending', label: t('taskPendingGroup'), items: pending }]" :key="grp.key">
<div v-if="grp.items.length" class="tc-group" :class="grp.key"><i />{{ grp.label }}<em>{{ grp.items.length }}</em></div>
<div v-for="x in grp.items" :key="x.kind + x.id" class="tc-item" @click="goto(x)">
<span class="tc-icon" :class="x.kind"><component :is="x.kind === 'todo' ? ListTodo : TicketCheck" /></span>
<div class="tc-main">
<b>{{ x.title }}</b>
<small>
<span v-if="x.projectName" class="tc-proj">{{ x.projectName }}</span>
<span v-if="x.priority === 'high'" class="tc-pri"><Flag />{{ t('priority.high') }}</span>
<time v-if="x.dueAt" :class="{ overdue: overdue(x) }">{{ x.dueAt.replace('T', ' ').slice(5, 16) }}</time>
</small>
</div>
<div class="tc-acts">
<button v-for="a in actions(x)" :key="a.s" :title="a.label" @click.stop="act(x, a.s)"><component :is="a.icon" />{{ a.label }}</button>
</div>
</div>
</template>
<div v-if="!badge" class="bell-empty">{{ t('taskEmpty') }}</div>
</div>
<button type="button" class="bell-more" @click="open = false; router.push('/today')">{{ t('viewAll') }}<ArrowRight /></button>
</div>
<TaskDetailModal v-if="detail" :kind="detail.kind" :item="detail.item" @close="detail = null" @changed="load" />
</div>
</template>

View File

@@ -0,0 +1,140 @@
<script setup>
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { X, ListTodo, TicketCheck, Flag, CalendarDays, Pencil, Trash2, Play, Check, Archive, RotateCcw, Share2 } from 'lucide-vue-next'
import { call, notifyTasksChanged } from '../api'
import { useAppStore } from '../store'
import MarkdownView from './MarkdownView.vue'
import LifecycleTimeline from './LifecycleTimeline.vue'
// 待办/工单详情模态框:查看内容与生命周期、就地流转状态。日历与顶栏任务中心共用。
const props = defineProps({
kind: { type: String, required: true }, // todo | ticket
item: { type: Object, required: true }
})
const emit = defineEmits(['close', 'changed'])
const router = useRouter()
const store = useAppStore()
const { t } = useI18n()
const cur = ref({ ...props.item })
const busy = ref(false)
// 共享到团队(登录且加入团队时才显示选择器)
const myTeams = ref([])
onMounted(async () => {
if (!store.syncStatus.loggedIn) return
try { myTeams.value = (await call('TeamList')) || [] } catch {}
})
async function setTeam(ev) {
const teamId = Number(ev.target.value) || 0
try {
await call(props.kind === 'ticket' ? 'SetTicketTeam' : 'SetTodoTeam', cur.value.id, teamId)
cur.value.teamId = teamId
store.showToast({ type: 'success', key: 'sharedToTeamToast' })
} catch (e) { store.showToast({ type: 'error', text: String(e) }) }
}
const content = computed(() => (props.kind === 'ticket' ? cur.value.description : cur.value.content) || '')
const dates = computed(() => {
const it = cur.value
if (props.kind === 'ticket') return it.startAt && it.startAt !== it.dueAt ? `${it.startAt}${it.dueAt}` : (it.dueAt || '')
return (it.dueAt || '').replace('T', ' ')
})
// 与工单页一致的状态流转:开始处理 → 解决 → 关闭;已解决/已关闭可重新打开
const flows = computed(() => {
if (props.kind !== 'ticket') return []
return {
open: [{ key: 'in_progress', label: t('ticketFlow.start'), icon: Play }],
in_progress: [{ key: 'resolved', label: t('ticketFlow.resolve'), icon: Check }],
resolved: [{ key: 'closed', label: t('ticketFlow.close'), icon: Archive }, { key: 'open', label: t('ticketFlow.reopen'), icon: RotateCcw }],
closed: [{ key: 'open', label: t('ticketFlow.reopen'), icon: RotateCcw }]
}[cur.value.status] || []
})
async function setStatus(status) {
if (busy.value || cur.value.status === status) return
busy.value = true
try {
await call(props.kind === 'ticket' ? 'SetTicketStatus' : 'SetTodoStatus', cur.value.id, status)
// 拉取最新条目,时间线立即显示新节点
const list = await call(props.kind === 'ticket' ? 'ListTickets' : 'ListTodos', 'all', 0)
cur.value = list.find(i => i.id === cur.value.id) || { ...cur.value, status }
notifyTasksChanged()
emit('changed')
} catch (e) {
store.showToast({ type: 'error', text: String(e) })
} finally {
busy.value = false
}
}
function edit() {
emit('close')
// 携带 edit 深链,进入列表页后直接弹出对应编辑框
router.push({ path: props.kind === 'ticket' ? '/tickets' : '/todos', query: { edit: cur.value.id } })
}
async function remove() {
if (busy.value || !confirm(`${t('delete')} ${cur.value.title}?`)) return
busy.value = true
try {
await call(props.kind === 'ticket' ? 'DeleteTicket' : 'DeleteTodo', cur.value.id)
notifyTasksChanged()
emit('changed')
emit('close')
} catch (e) {
store.showToast({ type: 'error', text: String(e) })
} finally {
busy.value = false
}
}
function onKey(e) {
if (e.key === 'Escape' && !busy.value) emit('close')
}
onMounted(() => addEventListener('keydown', onKey))
onUnmounted(() => removeEventListener('keydown', onKey))
</script>
<template>
<Teleport to="body">
<div class="overlay" @click.self="!busy && emit('close')">
<section class="modal cal-detail-modal" @click.stop>
<header>
<h2><component :is="kind === 'ticket' ? TicketCheck : ListTodo" class="panel-icon" />{{ cur.title }}</h2>
<button type="button" :disabled="busy" @click="emit('close')"><X /></button>
</header>
<div class="cal-detail-meta">
<span v-if="kind === 'ticket'" class="ticket-status" :class="cur.status">{{ t('ticketStatus.' + cur.status) }}</span>
<span v-else class="ticket-status" :class="'todo-' + cur.status">{{ t('todoStatus.' + cur.status) }}</span>
<span class="todo-priority" :class="cur.priority"><Flag />{{ t('priority.' + cur.priority) }}</span>
<span v-if="kind === 'ticket'" class="todo-chip">{{ t('ticketType.' + cur.type) }}</span>
<span v-if="cur.projectName" class="todo-chip">{{ cur.projectName }}</span>
<span v-if="dates" class="todo-due"><CalendarDays />{{ dates }}</span>
<label v-if="myTeams.length" class="share-team" :title="t('shareToTeam')">
<Share2 />
<select :value="cur.teamId || 0" @change="setTeam">
<option :value="0">{{ t('sharePrivate') }}</option>
<option v-for="tm in myTeams" :key="tm.id" :value="tm.id">{{ tm.name }}</option>
</select>
</label>
</div>
<div class="cal-detail-body">
<MarkdownView v-if="content" :source="content" />
<p v-else class="cal-detail-empty">{{ t('mdEmpty') }}</p>
<LifecycleTimeline :history="cur.history" :created-at="cur.createdAt" :updated-at="cur.updatedAt" :status="cur.status" :kind="kind" />
</div>
<footer class="cal-detail-foot">
<div v-if="kind === 'todo'" class="tabs compact">
<button v-for="s in ['open', 'doing', 'done']" :key="s" :class="{ active: cur.status === s }" :disabled="busy" @click="setStatus(s)">{{ t('todoStatus.' + s) }}</button>
</div>
<div v-else class="cal-detail-flow">
<button v-for="a in flows" :key="a.key" class="btn secondary flow-btn" :disabled="busy" @click="setStatus(a.key)"><component :is="a.icon" />{{ a.label }}</button>
</div>
<div class="icon-actions">
<button :title="t('edit')" :disabled="busy" @click="edit"><Pencil /></button>
<button :title="t('delete')" :disabled="busy" @click="remove"><Trash2 /></button>
</div>
</footer>
</section>
</div>
</Teleport>
</template>

View File

@@ -0,0 +1,68 @@
<script setup>
import { onMounted, onUnmounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { ArrowLeftRight, ArrowRight, Check, RefreshCw } from 'lucide-vue-next'
import { useAppStore } from '../store'
import { teams, teamsLoading, loadTeams, switchTeam, clearTeams } from '../team'
// 团队切换器:左侧 rail 常驻入口,弹出团队列表一键切换;当前团队名同步到窗口标题(见 team.js
const router = useRouter()
const store = useAppStore()
const { t } = useI18n()
const open = ref(false)
const wrap = ref(null)
const busy = ref(false)
async function toggle() {
open.value = !open.value
if (open.value && store.syncStatus.loggedIn) await loadTeams()
}
async function pick(tm) {
if (tm.current || busy.value) return
busy.value = true
try {
await switchTeam(tm.id)
store.showToast({ type: 'success', text: t('teamSwitchedToast', { name: tm.name }) })
open.value = false
} catch (e) {
store.showToast({ type: 'error', text: String(e) })
} finally { busy.value = false }
}
function onClickAway(e) {
if (wrap.value && !wrap.value.contains(e.target)) open.value = false
}
onMounted(() => {
addEventListener('click', onClickAway)
if (store.syncStatus.loggedIn) loadTeams()
})
onUnmounted(() => removeEventListener('click', onClickAway))
// 登录后拉取团队(顺带设置窗口标题),登出还原。
watch(() => store.syncStatus.loggedIn, v => { v ? loadTeams() : clearTeams() })
</script>
<template>
<div ref="wrap" class="bell-wrap">
<button class="bell-btn" :title="t('teamSwitcher')" @click="toggle">
<ArrowLeftRight />
</button>
<div v-if="open" class="bell-dropdown popover-glass ts-dropdown">
<header><b>{{ t('teamSwitcher') }}</b></header>
<div class="bell-list ts-list">
<template v-if="store.syncStatus.loggedIn">
<p v-if="teamsLoading" class="bell-empty"><RefreshCw class="spin" /> {{ t('loading') }}</p>
<template v-else-if="teams.length">
<button v-for="tm in teams" :key="tm.id" type="button" class="team-pick" :class="{ current: tm.current }" :disabled="busy" @click="pick(tm)">
<span class="team-pick-badge">{{ tm.name[0] }}</span>
<span class="team-pick-main"><b>{{ tm.name }}</b><small>{{ t('teamRole_' + tm.role) }} · {{ t('teamMembersCount', { n: tm.members }) }}</small></span>
<Check v-if="tm.current" class="team-pick-check" />
</button>
</template>
<div v-else class="bell-empty">{{ t('teamNoneHint') }}</div>
</template>
<div v-else class="bell-empty">{{ t('teamLoginHint') }}</div>
</div>
<button type="button" class="bell-more" @click="open = false; router.push('/team')">{{ t('teamGoHome') }}<ArrowRight /></button>
</div>
</div>
</template>

31
frontend/src/dateutil.js Normal file
View File

@@ -0,0 +1,31 @@
// dateutil.js 灵活日期输入解析:日历跳转与 DatePicker 手动输入共用。
// 支持 20260501 / 2026-05-01 / 2026/5/1 / 0501 / 05-01 / 5-1 / 501 等写法;
// 未带年份的按 defaultYear通常取当前视图年份补全。
export const pad2 = n => String(n).padStart(2, '0')
export const toYmd = d => `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`
const daysInMonth = (y, m) => new Date(y, m, 0).getDate()
function build(y, m, d) {
y = Number(y); m = Number(m); d = Number(d)
if (!y || y < 1900 || y > 9999) return ''
if (m < 1 || m > 12 || d < 1 || d > daysInMonth(y, m)) return ''
return `${y}-${pad2(m)}-${pad2(d)}`
}
// parseFlexDate 返回 'YYYY-MM-DD',解析失败返回 ''。
export function parseFlexDate(input, defaultYear = new Date().getFullYear()) {
const s = String(input || '').trim().replace(/[./年月]/g, '-').replace(/日$/, '')
if (!s) return ''
if (/^\d+$/.test(s)) {
if (s.length === 8) return build(s.slice(0, 4), s.slice(4, 6), s.slice(6)) // 20260501
if (s.length === 4) return build(defaultYear, s.slice(0, 2), s.slice(2)) // 0501
if (s.length === 3) return build(defaultYear, s.slice(0, 1), s.slice(1)) // 501
return ''
}
const parts = s.split('-').map(x => x.trim()).filter(Boolean)
if (parts.length === 3) return build(parts[0], parts[1], parts[2]) // 2026-5-1
if (parts.length === 2) return build(defaultYear, parts[0], parts[1]) // 05-01
return ''
}

151
frontend/src/eggart.js Normal file
View File

@@ -0,0 +1,151 @@
// 日历节日/节气彩蛋:手绘线条风 SVG 图案库(不用 emoji
// 每个节日映射一组图形 + 专属配色compose 成 dataURL 铺在格子背景。
// 图形均画在 24x24 视图内stroke 线条风,个别点缀用 fill。
const M = {
lantern: '<ellipse cx="12" cy="11.5" rx="6.2" ry="7"/><path d="M9 5.2h6M9 17.8h6M12 18v3"/><path d="M9.6 5.8v11.4M14.4 5.8v11.4"/>',
firecracker: '<rect x="9" y="7.5" width="6" height="11.5" rx="1.6"/><path d="M12 7.5V5M12 5c0-1.3 1.5-1.1 1.5-2.4"/><path d="M6.2 10.4l-2.2-1M6.2 14l-2.2 1M17.8 10.4l2.2-1M17.8 14l2.2 1"/>',
envelope: '<rect x="5.5" y="4" width="13" height="16" rx="2"/><path d="M5.5 8.2h13"/><circle cx="12" cy="13.6" r="2.5"/>',
moon: '<path d="M18.8 13.4A7.3 7.3 0 1 1 10.6 5a5.7 5.7 0 0 0 8.2 8.4z"/>',
mooncake: '<circle cx="12" cy="12" r="7.4"/><circle cx="12" cy="12" r="4.2" stroke-dasharray="2.2 2"/><circle cx="12" cy="12" r=".9" fill="currentColor" stroke="none"/>',
star: '<path d="M12 4.4l1.7 5.9L19.6 12l-5.9 1.7L12 19.6l-1.7-5.9L4.4 12l5.9-1.7z"/>',
heart: '<path d="M12 19.2S5.4 15 3.8 11.3A4.5 4.5 0 0 1 12 7.6a4.5 4.5 0 0 1 8.2 3.7C18.6 15 12 19.2 12 19.2z"/>',
leaf: '<path d="M5.2 18.8C5.2 9.4 12 5 19.2 5c0 8.2-5.4 13.8-14 13.8z"/><path d="M5.2 18.8C8 13.6 11.8 9.8 15.6 7.2"/>',
sprout: '<path d="M12 21v-8.2"/><path d="M12 12.8c0-3.8-2.9-6.2-6.8-6.2 0 4.3 2.9 6.2 6.8 6.2z"/><path d="M12 10.8c0-3.2 2.5-5.5 6.2-5.5-.3 3.7-2.7 5.5-6.2 5.5z"/>',
flower: '<circle cx="12" cy="12" r="2"/><circle cx="12" cy="6.6" r="2.4"/><circle cx="17.2" cy="10.3" r="2.4"/><circle cx="15.2" cy="16.4" r="2.4"/><circle cx="8.8" cy="16.4" r="2.4"/><circle cx="6.8" cy="10.3" r="2.4"/>',
rain: '<path d="M6.6 10.2a5.4 5.4 0 0 1 10.5-1.7 3.9 3.9 0 0 1-.4 7.7H8a3.9 3.9 0 0 1-1.4-6z"/><path d="M9 18.6l-.9 2M13 18.6l-.9 2M17 18.6l-.9 2"/>',
drop: '<path d="M12 3.6c3.5 4.1 5.8 7.2 5.8 10.2a5.8 5.8 0 0 1-11.6 0c0-3 2.3-6.1 5.8-10.2z"/>',
wheat: '<path d="M12 21V7.6"/><path d="M12 11.6c-2.4 0-4.3-1.9-4.3-4.3 2.4 0 4.3 1.9 4.3 4.3zM12 11.6c2.4 0 4.3-1.9 4.3-4.3-2.4 0-4.3 1.9-4.3 4.3zM12 15.8c-2.4 0-4.3-1.9-4.3-4.3 2.4 0 4.3 1.9 4.3 4.3zM12 15.8c2.4 0 4.3-1.9 4.3-4.3-2.4 0-4.3 1.9-4.3 4.3z"/>',
sun: '<circle cx="12" cy="12" r="4"/><path d="M12 3.2v2.2M12 18.6v2.2M3.2 12h2.2M18.6 12h2.2M5.8 5.8l1.6 1.6M16.6 16.6l1.6 1.6M18.2 5.8l-1.6 1.6M7.4 16.6l-1.6 1.6"/>',
snow: '<path d="M12 3.4v17.2M4.5 7.7l15 8.6M19.5 7.7l-15 8.6"/><path d="M12 6.8l-1.7-1.7M12 6.8l1.7-1.7M12 17.2l-1.7 1.7M12 17.2l1.7 1.7"/>',
fire: '<path d="M12 20.8c-3.6 0-6.2-2.4-6.2-5.7 0-2.4 1.5-4 2.9-5.6.3 1.2 1 1.9 1.9 2.2-.2-3.2.5-5.6 2.9-7.9-.4 2.8.7 4 2.2 5.7 1.2 1.4 2.5 2.8 2.5 5.6 0 3.3-2.6 5.7-6.2 5.7z"/>',
boat: '<path d="M4 15.8h16l-2.3 3.8H6.3z"/><path d="M12 15.8V4.4"/><path d="M12 5c3.8 1.3 5.7 4.3 5.7 8.1"/>',
candle: '<rect x="9.5" y="10.2" width="5" height="9.8" rx="1.2"/><path d="M12 10.2V8.2"/><path d="M12 3.6c1.2 1.3 1.2 2.7 0 4-1.2-1.3-1.2-2.7 0-4z"/>',
book: '<path d="M4.4 5.4A2.4 2.4 0 0 1 6.8 3h12.8v15.6H6.8a2.4 2.4 0 0 0-2.4 2.4z"/><path d="M19.6 18.6H6.8a2.4 2.4 0 0 0-2.4 2.4"/>',
balloon: '<ellipse cx="12" cy="8.8" rx="5.2" ry="6.2"/><path d="M12 15l-.9 1.5h1.8z"/><path d="M12 16.5c0 2-1.5 2-1.5 4.2"/>',
rocket: '<path d="M12 3.2c3 1.7 4.4 4.8 4.4 8.2l-1.9 4.8H9.5l-1.9-4.8c0-3.4 1.4-6.5 4.4-8.2z"/><circle cx="12" cy="9.2" r="1.7"/><path d="M9.5 16.2L7.6 19.6M14.5 16.2l1.9 3.4"/>',
gift: '<rect x="4.8" y="9.2" width="14.4" height="10.8" rx="1.6"/><path d="M12 9.2V20M4.8 13.6h14.4"/><path d="M12 9.2c-4.3 0-5.2-5.2-1.5-5.2 1.9 0 1.5 5.2 1.5 5.2zM12 9.2c4.3 0 5.2-5.2 1.5-5.2-1.9 0-1.5 5.2-1.5 5.2z"/>',
pine: '<path d="M12 3.2l4.8 5.8h-2.5l3.9 5.8h-4.4v3h-3.6v-3H5.8l3.9-5.8H7.2z"/><path d="M9 20.8h6"/>',
tree: '<circle cx="12" cy="9.2" r="5.4"/><path d="M12 14.6V21M9.4 21h5.2"/>',
pumpkin: '<path d="M12 7.4c4.8 0 7.8 2.6 7.8 6.6s-3 6.6-7.8 6.6-7.8-2.6-7.8-6.6 3-6.6 7.8-6.6z"/><path d="M9.5 7.8c-1.5 3.6-1.5 8.8 0 12.4M14.5 7.8c1.5 3.6 1.5 8.8 0 12.4"/><path d="M12 7.4V5c0-.9.8-1.5 1.9-1.5"/>',
bell: '<path d="M12 4.2a5.8 5.8 0 0 1 5.8 5.8v3.8l1.7 2.5H4.5L6.2 13.8V10A5.8 5.8 0 0 1 12 4.2z"/><path d="M10.1 19.2a1.9 1.9 0 0 0 3.8 0"/>',
wind: '<path d="M3.4 8.4h9.2a2.5 2.5 0 1 0-2.5-2.5M3.4 12.8h13.9a2.5 2.5 0 1 1-2.5 2.5M3.4 17.2h6.7a2.1 2.1 0 1 1-2.1 2.1"/>',
mountain: '<path d="M3.2 18.8L9.4 7.4l3.8 6.6 1.9-3.2 5.7 8z"/>',
wave: '<path d="M3.2 11c2.4-2.9 5.3-2.9 7.6 0s5.2 2.9 7.6 0M3.2 16.2c2.4-2.9 5.3-2.9 7.6 0s5.2 2.9 7.6 0"/>',
ice: '<path d="M12 3.2l7.6 4.4v8.8L12 20.8l-7.6-4.4V7.6z"/><path d="M12 3.2v17.6M4.4 7.6l15.2 8.8M19.6 7.6L4.4 16.4" opacity=".45"/>',
gear: '<circle cx="12" cy="12" r="3"/><path d="M12 3v2.6M12 18.4V21M3 12h2.6M18.4 12H21M5.6 5.6l1.9 1.9M16.5 16.5l1.9 1.9M18.4 5.6l-1.9 1.9M7.5 16.5l-1.9 1.9"/>',
bolt: '<path d="M13.2 3L6 13.4h4.9L9.4 21 18 10.6h-4.9z"/>',
firework: '<circle cx="12" cy="12" r="1.1" fill="currentColor" stroke="none"/><path d="M12 3.6v3M12 17.4v3M3.6 12h3M17.4 12h3M6 6l2.1 2.1M15.9 15.9L18 18M18 6l-2.1 2.1M8.1 15.9L6 18"/><circle cx="12" cy="12" r="7.6" stroke-dasharray="1.4 3.4"/>',
bowl: '<path d="M4 11.4h16a8 8 0 0 1-16 0z"/><path d="M8.4 8.4c1.4-1.4 3.6-.9 3.6.9M12.6 7.2c1.4-1.4 3.6-.9 3.6.9"/>',
dumpling: '<path d="M4 14.4a8 8 0 0 1 16 0c0 1.9-1.7 3.8-3.8 3.8H7.8c-2.1 0-3.8-1.9-3.8-3.8z"/><path d="M8.2 9.8L9.3 7.6M12 8.9V6.6M15.8 9.8l-1.1-2.2"/>'
}
// 每个节日i = [主图形, 点缀1, 点缀2]c = [主色, 点缀色]
const RULES = [
['除夕', { i: ['firecracker', 'lantern', 'star'], c: ['#f0656e', '#e7bd6a'] }],
['春节', { i: ['envelope', 'lantern', 'star'], c: ['#f0656e', '#e7bd6a'] }],
['元宵', { i: ['lantern', 'moon', 'star'], c: ['#f08c5a', '#e7bd6a'] }],
['龙头', { i: ['wave', 'sun', 'star'], c: ['#4fb6c9', '#e7bd6a'] }],
['端午', { i: ['boat', 'leaf', 'drop'], c: ['#43c996', '#4fb6c9'] }],
['七夕', { i: ['heart', 'star', 'moon'], c: ['#ef7ea8', '#9a8cf8'] }],
['中元', { i: ['candle', 'moon', 'star'], c: ['#e7bd6a', '#9a8cf8'] }],
['中秋', { i: ['mooncake', 'moon', 'star'], c: ['#e7bd6a', '#9a8cf8'] }],
['重阳', { i: ['flower', 'mountain', 'leaf'], c: ['#ef9950', '#c98f5a'] }],
['腊八', { i: ['bowl', 'wheat', 'snow'], c: ['#c98f5a', '#6db5ee'] }],
['元旦', { i: ['firework', 'star', 'bell'], c: ['#9a8cf8', '#e7bd6a'] }],
['情人', { i: ['heart', 'star', 'gift'], c: ['#ef7ea8', '#f0656e'] }],
['妇女', { i: ['flower', 'heart', 'sprout'], c: ['#ef7ea8', '#43c996'] }],
['植树', { i: ['tree', 'sprout', 'leaf'], c: ['#43c996', '#7ac96b'] }],
['愚人', { i: ['balloon', 'star', 'wind'], c: ['#ef9950', '#9a8cf8'] }],
['劳动', { i: ['gear', 'wheat', 'sun'], c: ['#e7bd35', '#c98f5a'] }],
['青年', { i: ['rocket', 'star', 'wind'], c: ['#5b9df5', '#9a8cf8'] }],
['儿童', { i: ['balloon', 'gift', 'star'], c: ['#4fb6c9', '#ef7ea8'] }],
['教师', { i: ['book', 'star', 'flower'], c: ['#5b9df5', '#e7bd6a'] }],
['国庆', { i: ['firework', 'lantern', 'star'], c: ['#f0656e', '#e7bd6a'] }],
['万圣', { i: ['pumpkin', 'moon', 'star'], c: ['#ef9950', '#9a8cf8'] }],
['感恩', { i: ['wheat', 'leaf', 'heart'], c: ['#c98f5a', '#ef9950'] }],
['平安夜', { i: ['bell', 'star', 'snow'], c: ['#e7bd6a', '#43c996'] }],
['圣诞', { i: ['pine', 'gift', 'snow'], c: ['#43c996', '#f0656e'] }],
['母亲', { i: ['flower', 'heart', 'sprout'], c: ['#ef7ea8', '#f0656e'] }],
['父亲', { i: ['mountain', 'heart', 'star'], c: ['#5b9df5', '#e7bd6a'] }],
// 二十四节气:春绿 / 夏金 / 秋橙 / 冬蓝
['立春', { i: ['sprout', 'leaf', 'rain'], c: ['#43c996', '#7ac96b'] }],
['雨水', { i: ['rain', 'drop', 'sprout'], c: ['#4fb6c9', '#43c996'] }],
['惊蛰', { i: ['bolt', 'sprout', 'rain'], c: ['#e7bd35', '#43c996'] }],
['春分', { i: ['flower', 'leaf', 'sun'], c: ['#7ac96b', '#e7bd6a'] }],
['清明', { i: ['leaf', 'rain', 'sprout'], c: ['#43c996', '#4fb6c9'] }],
['谷雨', { i: ['wheat', 'rain', 'drop'], c: ['#7ac96b', '#4fb6c9'] }],
['立夏', { i: ['sun', 'flower', 'leaf'], c: ['#e7bd35', '#43c996'] }],
['小满', { i: ['wheat', 'drop', 'sun'], c: ['#d9c05a', '#4fb6c9'] }],
['芒种', { i: ['wheat', 'sun', 'wind'], c: ['#d9c05a', '#e7bd35'] }],
['夏至', { i: ['sun', 'wave', 'drop'], c: ['#e7bd35', '#4fb6c9'] }],
['小暑', { i: ['fire', 'wave', 'sun'], c: ['#ef9950', '#4fb6c9'] }],
['大暑', { i: ['fire', 'sun', 'wave'], c: ['#f0656e', '#e7bd35'] }],
['立秋', { i: ['leaf', 'wheat', 'wind'], c: ['#ef9950', '#c98f5a'] }],
['处暑', { i: ['leaf', 'sun', 'wind'], c: ['#ef9950', '#e7bd6a'] }],
['白露', { i: ['drop', 'moon', 'leaf'], c: ['#6db5ee', '#ef9950'] }],
['秋分', { i: ['leaf', 'moon', 'wheat'], c: ['#c98f5a', '#e7bd6a'] }],
['寒露', { i: ['drop', 'snow', 'leaf'], c: ['#6db5ee', '#ef9950'] }],
['霜降', { i: ['snow', 'leaf', 'wind'], c: ['#6db5ee', '#c98f5a'] }],
['立冬', { i: ['snow', 'wind', 'mountain'], c: ['#6db5ee', '#9a8cf8'] }],
['小雪', { i: ['snow', 'wind', 'star'], c: ['#6db5ee', '#b8d4f2'] }],
['大雪', { i: ['snow', 'mountain', 'wind'], c: ['#8cc3f0', '#6db5ee'] }],
['冬至', { i: ['dumpling', 'snow', 'lantern'], c: ['#e7bd6a', '#6db5ee'] }],
['小寒', { i: ['ice', 'snow', 'wind'], c: ['#6db5ee', '#9a8cf8'] }],
['大寒', { i: ['ice', 'mountain', 'snow'], c: ['#8cc3f0', '#9a8cf8'] }]
]
export function festEgg(names) {
for (const n of names || []) {
for (const [k, egg] of RULES) if (n.includes(k)) return { key: k, ...egg }
}
return null
}
const place = (name, x, y, s, rot, color, op) =>
`<g transform="translate(${x} ${y}) rotate(${rot}) scale(${s}) translate(-12 -12)" fill="none" stroke="${color}" color="${color}" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" opacity="${op}">${M[name] || M.star}</g>`
const cache = new Map()
export function eggBg(egg) {
if (!egg) return ''
const key = egg.i.join() + egg.c.join()
if (cache.has(key)) return cache.get(key)
const [main, a1, a2] = egg.i
const [c1, c2] = egg.c
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 192 192">${place(a1, 42, 44, 2.1, -16, c2, .8)}${place(a2, 150, 50, 1.7, 14, c2, .65)}${place(main, 96, 118, 3.8, -7, c1, 1)}</svg>`
const url = `url("data:image/svg+xml,${encodeURIComponent(svg)}")`
cache.set(key, url)
return url
}
// eggArt整卡插画背景深色渐变底 + 节日色光晕 + 装饰环/星点 + 大图案),
// 铺满日历格当"配图"用;管理员未上传自定义照片时的默认背景。
const artCache = new Map()
export function eggArt(egg) {
if (!egg) return ''
const key = 'art:' + egg.i.join() + egg.c.join()
if (artCache.has(key)) return artCache.get(key)
const [main, a1, a2] = egg.i
const [c1, c2] = egg.c
const dots = [[26, 92, 1.6], [58, 22, 1.2], [104, 40, 1.8], [170, 96, 1.3], [148, 160, 1.6], [36, 168, 1.2], [176, 22, 1.1]]
.map(([x, y, r], n) => `<circle cx="${x}" cy="${y}" r="${r}" fill="${n % 2 ? c2 : c1}" opacity="${n % 2 ? .5 : .38}"/>`).join('')
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 192 192">
<defs>
<linearGradient id="b" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#161c2e"/><stop offset="1" stop-color="#0b0f1a"/></linearGradient>
<radialGradient id="p" cx="26%" cy="18%" r="80%"><stop offset="0" stop-color="${c1}" stop-opacity=".5"/><stop offset="1" stop-color="${c1}" stop-opacity="0"/></radialGradient>
<radialGradient id="q" cx="88%" cy="94%" r="86%"><stop offset="0" stop-color="${c2}" stop-opacity=".42"/><stop offset="1" stop-color="${c2}" stop-opacity="0"/></radialGradient>
</defs>
<rect width="192" height="192" fill="url(#b)"/><rect width="192" height="192" fill="url(#p)"/><rect width="192" height="192" fill="url(#q)"/>
<circle cx="158" cy="30" r="44" fill="none" stroke="${c2}" stroke-opacity=".22" stroke-width="1.4"/>
<circle cx="158" cy="30" r="58" fill="none" stroke="${c2}" stroke-opacity=".1" stroke-width="1.2"/>
<circle cx="14" cy="148" r="30" fill="none" stroke="${c1}" stroke-opacity=".18" stroke-width="1.4"/>
${dots}
${place(a1, 38, 52, 1.9, -14, c2, .62)}
${place(a2, 152, 66, 1.5, 12, c2, .5)}
${place(main, 118, 134, 3.9, -6, '#0a0e18', .6)}
${place(main, 116, 132, 3.7, -6, c1, .95)}
</svg>`
const url = `url("data:image/svg+xml,${encodeURIComponent(svg)}")`
artCache.set(key, url)
return url
}

53
frontend/src/mdeditor.js Normal file
View File

@@ -0,0 +1,53 @@
// 待办/工单编辑器共用的 Markdown 能力:编辑/预览切换、粘贴图片、选图插入。
// 图片经后端 SaveContentImage 处理(缩放压缩,按设置存 base64 或本地文件)。
import { ref } from 'vue'
import { call } from './api'
export function useMdEditor(form, key, onError) {
const preview = ref(false)
const inputEl = ref(null)
const uploading = ref(false)
function reset() {
preview.value = false
uploading.value = false
}
function insert(text) {
const el = inputEl.value
const v = form[key] || ''
const s = el && el.selectionStart != null ? el.selectionStart : v.length
const e = el && el.selectionEnd != null ? el.selectionEnd : v.length
form[key] = v.slice(0, s) + text + v.slice(e)
}
const snippet = v => `${form[key] && !form[key].endsWith('\n') ? '\n' : ''}![img](${v})\n`
async function onPaste(e) {
const item = [...(e.clipboardData?.items || [])].find(i => i.type.startsWith('image/'))
if (!item) return
e.preventDefault()
const file = item.getAsFile()
if (!file) return
const b64 = await new Promise(r => {
const fr = new FileReader()
fr.onload = () => r(fr.result)
fr.readAsDataURL(file)
})
uploading.value = true
try { insert(snippet(await call('SaveContentImage', b64))) }
catch (err) { onError(err) }
finally { uploading.value = false }
}
async function pickImage() {
uploading.value = true
try {
const v = await call('PickContentImage')
if (v) insert(snippet(v))
} catch (err) { onError(err) }
finally { uploading.value = false }
}
return { preview, inputEl, uploading, reset, onPaste, pickImage }
}

56
frontend/src/team.js Normal file
View File

@@ -0,0 +1,56 @@
// team.js 团队共享状态:团队列表 / 当前团队 / 角色,避免各页面各自维护。
// 同时负责把当前团队名同步到原生窗口标题。
import { computed, ref } from 'vue'
import { Window } from '@wailsio/runtime'
import { call, isNative } from './api'
import { useAppStore } from './store'
export const teams = ref([])
export const teamsErr = ref('')
export const teamsLoading = ref(false)
export const currentTeam = computed(() => teams.value.find(x => x.current) || null)
export const isTeamAdmin = computed(() => ['owner', 'admin'].includes(currentTeam.value?.role))
// 与 main.go 窗口初始标题保持一致。
const BASE_TITLE = '年糕崽崽项目管理PMS'
function syncTitle() {
if (!isNative()) return
const cur = teams.value.find(x => x.current)
try { Window.SetTitle(cur ? `${BASE_TITLE} · ${cur.name}` : BASE_TITLE) } catch { /* 运行时未就绪时忽略 */ }
}
export async function loadTeams() {
teamsLoading.value = true
teamsErr.value = ''
try {
teams.value = (await call('TeamList')) || []
} catch (e) {
teams.value = []
teamsErr.value = String(e)
} finally {
teamsLoading.value = false
syncTitle()
}
}
export async function switchTeam(id) {
await call('TeamSwitch', id)
teams.value.forEach(x => { x.current = x.id === id })
syncTitle()
// 团队任务徽标按当前团队统计,切换后立即刷新
try { useAppStore().refreshBadges() } catch { /* pinia 未就绪时忽略 */ }
}
// clearTeams 登出时清空团队状态并还原窗口标题。
export function clearTeams() {
teams.value = []
teamsErr.value = ''
syncTitle()
}
// teamErrKey 把后端错误串转成 i18n keyerrors.XXX未识别返回空。
export function teamErrCode(e) {
return String(e).split(':')[0].trim()
}

View File

@@ -0,0 +1,268 @@
<script setup>
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { Sparkles, Plus, Trash2, SendHorizonal, Square, Bot, User, PieChart, GitCommitHorizontal, ListTodo, TicketCheck, Settings, Folder } from 'lucide-vue-next'
import { marked } from 'marked'
import hljs from 'highlight.js/lib/common'
import 'highlight.js/styles/github-dark.css'
import { call, on } from '../api'
import { useAppStore } from '../store'
const route = useRoute()
const router = useRouter()
const store = useAppStore()
const { t } = useI18n()
const conversations = ref([])
const activeId = ref(0)
const messages = ref([])
const input = ref('')
const streamingId = ref(0)
const streamText = ref('')
const streamError = ref('')
const projectId = ref(Number(route.query.project) || 0)
const scenario = ref('chat')
const listEl = ref(null)
const streamBuf = {}
let offStream = null
const streaming = computed(() => streamingId.value !== 0 && streamingId.value === activeId.value)
// 会话区“虚拟滚动”:只渲染最近 N 条,向上滚动增量加载更早消息,长会话不卡顿。
const WINDOW = 40
const windowSize = ref(WINDOW)
const visibleMessages = computed(() => messages.value.slice(-windowSize.value))
const hiddenCount = computed(() => Math.max(0, messages.value.length - windowSize.value))
function expandWindow() {
const el = listEl.value
if (!hiddenCount.value || !el) return
const prevH = el.scrollHeight
const prevTop = el.scrollTop
windowSize.value += 60
// 渲染更早消息后保持视觉位置不跳
nextTick(() => { el.scrollTop = el.scrollHeight - prevH + prevTop })
}
function onListScroll() {
if (listEl.value && listEl.value.scrollTop < 40 && hiddenCount.value) expandWindow()
}
marked.setOptions({ breaks: true, gfm: true })
const md = s => marked.parse(s || '')
const activeConv = computed(() => conversations.value.find(c => c.id === activeId.value))
const providerLabel = computed(() => store.settings.aiProvider === 'deepseek' ? 'DeepSeek' : t('aiSparkLite'))
const hasKey = computed(() => store.settings.aiProvider === 'deepseek' ? !!store.settings.deepSeekKey : !!store.settings.sparkKey)
const quicks = [
{ key: 'project', icon: PieChart, label: 'aiQuickProject', prompt: 'aiPromptProject' },
{ key: 'git', icon: GitCommitHorizontal, label: 'aiQuickGit', prompt: 'aiPromptGit' },
{ key: 'todo', icon: ListTodo, label: 'aiQuickTodo', prompt: 'aiPromptTodo' },
{ key: 'ticket', icon: TicketCheck, label: 'aiQuickTicket', prompt: 'aiPromptTicket' }
]
function errText(e) {
const code = String(e).split(':')[0].trim()
return t('errors.' + code) !== 'errors.' + code ? t('errors.' + code) : String(e)
}
async function loadConversations() {
conversations.value = await call('ListAIConversations', 0)
}
async function select(c) {
activeId.value = c.id
projectId.value = c.projectId
streamError.value = ''
streamText.value = streamBuf[c.id] || ''
windowSize.value = WINDOW
messages.value = await call('GetAIMessages', c.id)
scrollBottom()
}
function newChat() {
activeId.value = 0
messages.value = []
streamError.value = ''
streamText.value = ''
}
async function del(c) {
if (!confirm(t('aiDeleteConfirm'))) return
await call('DeleteAIConversation', c.id)
if (activeId.value === c.id) newChat()
await loadConversations()
}
async function send(text, scen) {
const content = (text ?? input.value).trim()
if (!content || streaming.value) return
streamError.value = ''
try {
const conv = await call('SendAIMessage', activeId.value, projectId.value, scen || scenario.value, content)
if (!activeId.value) {
activeId.value = conv.id
await loadConversations()
}
messages.value.push({ id: -Date.now(), role: 'user', content })
input.value = ''
streamingId.value = conv.id
streamBuf[conv.id] = ''
streamText.value = ''
scrollBottom()
} catch (e) {
streamError.value = errText(e)
}
}
function quick(q) {
if (!projectId.value) { streamError.value = t('aiNeedProject'); return }
send(t(q.prompt), q.key)
}
async function stop() {
try { await call('StopAIStream', streamingId.value || activeId.value) } catch {}
}
// RFC3339(UTC) 转本地时区的 MM-DD HH:mm
function convTime(at) {
const d = new Date(at)
if (isNaN(d)) return ''
const p = n => String(n).padStart(2, '0')
return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`
}
function scrollBottom() {
nextTick(() => { if (listEl.value) listEl.value.scrollTop = listEl.value.scrollHeight })
}
function highlightAll() {
nextTick(() => {
listEl.value?.querySelectorAll('pre code:not([data-hl])').forEach(el => {
el.dataset.hl = '1'
hljs.highlightElement(el)
})
})
}
watch(visibleMessages, highlightAll, { deep: true })
// 已在本页时通过全局搜索跳转到其它会话
watch(() => route.query.conversation, async v => {
const conv = conversations.value.find(c => c.id === Number(v))
if (conv && conv.id !== activeId.value) await select(conv)
})
onMounted(async () => {
await loadConversations()
const deepConv = conversations.value.find(c => c.id === Number(route.query.conversation))
if (deepConv) {
// 全局搜索 / 深链进入:直接选中指定会话
await select(deepConv)
} else if (route.query.project) {
// 从项目详情进入:优先打开该项目最近的会话,否则新建并预选该项目
const conv = conversations.value.find(c => c.projectId === projectId.value)
if (conv) await select(conv)
else newChat()
// 详情页就地提问:携带 ask 参数时自动发送,并清掉 query 防止刷新重发
const ask = String(route.query.ask || '').trim()
if (ask && hasKey.value) {
router.replace('/ai')
await send(ask)
}
} else if (conversations.value.length) {
await select(conversations.value[0])
}
offStream = on('ai:stream', e => {
const cid = e.conversationId
if (e.delta) {
streamBuf[cid] = (streamBuf[cid] || '') + e.delta
if (cid === activeId.value) {
streamText.value = streamBuf[cid]
scrollBottom()
}
}
if (e.done) {
if (cid === streamingId.value) streamingId.value = 0
const finalText = streamBuf[cid] || ''
delete streamBuf[cid]
if (cid === activeId.value) {
if (e.error) streamError.value = errText(e.error)
if (finalText) messages.value.push({ id: e.messageId || -Date.now(), role: 'assistant', content: finalText })
streamText.value = ''
highlightAll()
}
loadConversations()
}
})
})
onUnmounted(() => offStream?.())
</script>
<template>
<div class="page ai-page">
<header class="page-head sticky-head">
<div><h1>{{ t('aiChat') }}</h1><p>{{ t('aiSubtitle') }}</p></div>
<div class="actions">
<span class="ai-provider-badge"><Sparkles />{{ providerLabel }}</span>
<button class="btn secondary" @click="router.push('/settings?tab=ai')"><Settings />{{ t('aiKeys') }}</button>
<button class="btn primary" @click="newChat"><Plus />{{ t('aiNewChat') }}</button>
</div>
</header>
<div v-if="!hasKey" class="preview-notice panel ai-key-notice">
<Sparkles />
<div><b>{{ t('aiNoKeyTitle') }}</b><small>{{ t('aiNoKeyHint') }}</small></div>
<button class="btn primary" @click="router.push('/settings?tab=ai')">{{ t('aiConfigureNow') }}</button>
</div>
<div class="ai-layout">
<aside class="panel ai-history">
<h2>{{ t('aiHistory') }}<small>{{ conversations.length }}</small></h2>
<div class="ai-conv-list">
<button v-for="c in conversations" :key="c.id" class="ai-conv" :class="{ active: c.id === activeId }" @click="select(c)">
<b>{{ c.title }}</b>
<small>
<span v-if="c.projectName" class="todo-chip">{{ c.projectName }}</span>
{{ convTime(c.updatedAt) }}
</small>
<i class="ai-conv-del" :title="t('delete')" @click.stop="del(c)"><Trash2 /></i>
</button>
<div v-if="!conversations.length" class="empty wb-empty">{{ t('aiNoHistory') }}</div>
</div>
</aside>
<section class="panel ai-chat">
<div class="ai-toolbar">
<label class="ai-project-pick">
<Folder />
<select v-model.number="projectId" :disabled="!!activeConv">
<option :value="0">{{ t('aiNoProject') }}</option>
<option v-for="p in store.projects" :key="p.id" :value="p.id">{{ p.name }}</option>
</select>
</label>
<div class="ai-quicks">
<button v-for="q in quicks" :key="q.key" class="btn secondary" :disabled="streaming || !hasKey" :title="!projectId ? t('aiNeedProject') : ''" @click="quick(q)">
<component :is="q.icon" />{{ t(q.label) }}
</button>
</div>
</div>
<div ref="listEl" class="ai-messages" @scroll.passive="onListScroll">
<div v-if="!messages.length && !streaming" class="ai-welcome">
<Bot />
<p>{{ t('aiWelcome') }}</p>
</div>
<button v-if="hiddenCount" class="ai-load-earlier" @click="expandWindow">{{ t('aiLoadEarlier', { n: hiddenCount }) }}</button>
<div v-for="m in visibleMessages" :key="m.id" class="ai-msg" :class="m.role">
<span class="ai-avatar"><component :is="m.role === 'user' ? User : Bot" /></span>
<div v-if="m.role === 'assistant'" class="ai-bubble markdown" v-html="md(m.content)" />
<div v-else class="ai-bubble">{{ m.content }}</div>
</div>
<div v-if="streaming" class="ai-msg assistant">
<span class="ai-avatar"><Bot /></span>
<div class="ai-bubble markdown">
<div v-if="streamText" v-html="md(streamText)" />
<span class="ai-typing"><i /><i /><i /></span>
</div>
</div>
<p v-if="streamError" class="ai-error">{{ streamError }}</p>
</div>
<div class="ai-input-row">
<textarea v-model="input" :placeholder="t('aiAskPlaceholder')" rows="2" :disabled="!hasKey"
@keydown.enter.exact.prevent="send()" />
<button v-if="streaming" class="btn secondary ai-send" @click="stop"><Square />{{ t('aiStop') }}</button>
<button v-else class="btn primary ai-send" :disabled="!input.trim() || !hasKey" @click="send()"><SendHorizonal />{{ t('aiSend') }}</button>
</div>
</section>
</div>
</div>
</template>

View File

@@ -0,0 +1,504 @@
<script setup>
import { computed, nextTick, onMounted, onUnmounted, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { ChevronLeft, ChevronRight, CalendarDays, ListTodo, TicketCheck, ArrowRight, Plus, AlarmClock, X, ImagePlus, Sparkles, Check, Image as ImageIcon } from 'lucide-vue-next'
import { Solar } from 'lunar-javascript'
import { call, notifyTasksChanged, onTasksChanged } from '../api'
import { parseFlexDate } from '../dateutil'
import { useAppStore } from '../store'
import MarkdownView from '../components/MarkdownView.vue'
import DueQuickPick from '../components/DueQuickPick.vue'
import DatePicker from '../components/DatePicker.vue'
import TaskDetailModal from '../components/TaskDetailModal.vue'
import AIScopeDrawer from '../components/AIScopeDrawer.vue'
import { useMdEditor } from '../mdeditor'
import { festEgg, eggBg, eggArt } from '../eggart'
const store = useAppStore()
const { t, locale } = useI18n()
const todos = ref([])
const tickets = ref([])
const cursor = ref(new Date())
const selected = ref('')
const aiOpen = ref(false)
const ymd = d => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
const todayStr = ymd(new Date())
const lunarOn = computed(() => locale.value === 'zh-CN')
// ---- 头部年月导航:年/月分别可点快选,输入框支持灵活格式直达某天 ----
const monthNames = computed(() => locale.value === 'zh-CN'
? ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月']
: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'])
const dispYear = computed(() => locale.value === 'zh-CN' ? `${cursor.value.getFullYear()}` : String(cursor.value.getFullYear()))
const dispMonth = computed(() => monthNames.value[cursor.value.getMonth()])
const ymPick = ref('') // '' | 'year' | 'month'
const yearBase = ref(0)
const yearWindow = computed(() => Array.from({ length: 12 }, (_, i) => yearBase.value + i))
const calSwitchEl = ref(null)
function openYm(kind) {
ymPick.value = ymPick.value === kind ? '' : kind
if (ymPick.value === 'year') yearBase.value = Math.floor(cursor.value.getFullYear() / 12) * 12
}
function setYear(y) { cursor.value = new Date(y, cursor.value.getMonth(), 1); ymPick.value = '' }
function setMonth(i) { cursor.value = new Date(cursor.value.getFullYear(), i, 1); ymPick.value = '' }
function onDocDown(e) { if (ymPick.value && calSwitchEl.value && !calSwitchEl.value.contains(e.target)) ymPick.value = '' }
// 跳转某天:支持 20260501 / 2026-05-01 / 0501 / 05-01 等,缺年份按当前视图年份补全
const jumpVal = ref('')
function jumpTo() {
const p = parseFlexDate(jumpVal.value, cursor.value.getFullYear())
if (!p) { store.showToast({ type: 'error', key: 'calBadDate' }); return }
cursor.value = new Date(Number(p.slice(0, 4)), Number(p.slice(5, 7)) - 1, 1)
selected.value = p
jumpVal.value = ''
}
// 农历 / 节气 / 节假日lunar-javascript含农历节日、24 节气、公历与“第 n 个星期 x”规则节日
// 彩蛋图案由 eggart.js 提供SVG 线条画 + 节日专属配色
function lunarInfo(d) {
const solar = Solar.fromYmd(d.getFullYear(), d.getMonth() + 1, d.getDate())
const lunar = solar.getLunar()
const jieQi = lunar.getJieQi()
const fests = [...lunar.getFestivals(), ...solar.getFestivals()]
const dayLabel = lunar.getDay() === 1 ? lunar.getMonthInChinese() + '月' : lunar.getDayInChinese()
const tags = [...fests, ...(jieQi ? [jieQi] : [])]
return {
label: dayLabel,
festLabel: fests[0] || jieQi || '',
fest: fests.length > 0,
jieqi: !fests.length && !!jieQi,
full: `${lunar.getYearInGanZhi()}${lunar.getYearShengXiao()}${lunar.getMonthInChinese()}${lunar.getDayInChinese()}`,
tags,
egg: festEgg(tags)
}
}
// 6x7 月视图网格(周日起始)
const grid = computed(() => {
const first = new Date(cursor.value.getFullYear(), cursor.value.getMonth(), 1)
const start = new Date(first)
start.setDate(1 - first.getDay())
const cells = []
for (let i = 0; i < 42; i++) {
const d = new Date(start)
d.setDate(start.getDate() + i)
const date = ymd(d)
cells.push({
date,
day: d.getDate(),
inMonth: d.getMonth() === cursor.value.getMonth(),
today: date === todayStr,
lunar: lunarOn.value ? lunarInfo(d) : null,
todos: todosByDate.value.get(date) || [],
tickets: ticketsByDate.value.get(date) || []
})
}
return cells
})
const dateOf = v => (v || '').slice(0, 10)
const todosByDate = computed(() => {
const m = new Map()
for (const x of todos.value) {
if (!x.dueAt || x.status === 'done') continue
const k = dateOf(x.dueAt)
m.set(k, [...(m.get(k) || []), x])
}
return m
})
// 工单在 start→due 区间内每天都记一条(限制区间过长时只标记首尾+当月)
const ticketsByDate = computed(() => {
const m = new Map()
for (const x of tickets.value) {
if (['resolved', 'closed'].includes(x.status)) continue
const s = new Date(dateOf(x.startAt))
const e = new Date(dateOf(x.dueAt))
if (isNaN(s) || isNaN(e)) continue
for (let d = new Date(s); d <= e; d.setDate(d.getDate() + 1)) {
const k = ymd(d)
m.set(k, [...(m.get(k) || []), { ...x, isStart: k === dateOf(x.startAt), isEnd: k === dateOf(x.dueAt) }])
if (m.get(k).length > 20) break
}
}
return m
})
const selectedCell = computed(() => grid.value.find(c => c.date === selected.value))
// 点击上/下月的日期时自动翻到对应月份,选中保持在该日期上
function onCellClick(c) {
selected.value = c.date
if (!c.inMonth) cursor.value = new Date(Number(c.date.slice(0, 4)), Number(c.date.slice(5, 7)) - 1, 1)
}
// 格子背景photo 模式且图片可用时显示照片,否则(无图 / art 模式 / 加载失败)回退动态插画
function cellArt(c) {
if (!c.lunar) return null
for (const tg of c.lunar.tags) {
const it = store.festivalImages[tg]
if (it && it.mode === 'photo' && it.image && !store.festBroken[tg]) {
return { cls: 'has-photo', style: { '--art-bg': `url("${it.image}")` } }
}
}
if (c.lunar.egg) return { cls: 'has-art', style: { '--art-bg': eggArt(c.lunar.egg) } }
return null
}
// ---- 节日配图管理(云端账号 id=1 专属)----
const isAdmin = computed(() => store.syncStatus.userId === 1)
const festBusy = ref(false)
const festAdmin = ref(false)
async function pickFestImg(tag) {
if (festBusy.value) return
festBusy.value = true
try {
const f = await call('PickFestivalImage', tag)
if (f && f.image) {
store.festivalImages = { ...store.festivalImages, [tag]: f }
store.showToast({ type: 'success', key: 'festImgSaved' })
}
} catch (e) {
store.showToast({ type: 'error', text: String(e) })
} finally {
festBusy.value = false
}
}
async function setFestMode(tag, mode) {
const it = store.festivalImages[tag]
if (festBusy.value || !it || it.mode === mode) return
festBusy.value = true
try {
await call('SetFestivalImageMode', tag, mode)
store.festivalImages = { ...store.festivalImages, [tag]: { ...it, mode } }
} catch (e) {
store.showToast({ type: 'error', text: String(e) })
} finally {
festBusy.value = false
}
}
const hasFestImg = tg => !!(store.festivalImages[tg] && store.festivalImages[tg].image)
const festModeOf = tg => (hasFestImg(tg) && store.festivalImages[tg].mode === 'photo' ? 'photo' : 'art')
const festPhotoStyle = tg => (hasFestImg(tg) ? { backgroundImage: `url("${store.festivalImages[tg].image}")` } : null)
// 左卡:切回动态插画(图片保留,随时可切回);右卡:无图先选图,有图则启用图片模式
function chooseFestArt(tg) {
if (festModeOf(tg) === 'photo') setFestMode(tg, 'art')
}
function chooseFestPhoto(tg) {
if (!hasFestImg(tg)) return pickFestImg(tg)
if (festModeOf(tg) === 'art') setFestMode(tg, 'photo')
}
async function removeFestImg(tag) {
if (festBusy.value) return
festBusy.value = true
try {
await call('RemoveFestivalImage', tag)
const next = { ...store.festivalImages }
delete next[tag]
store.festivalImages = next
store.showToast({ type: 'success', key: 'festImgRemoved' })
} catch (e) {
store.showToast({ type: 'error', text: String(e) })
} finally {
festBusy.value = false
}
}
// ---- 右键菜单与快速创建 ----
const ctx = reactive({ open: false, x: 0, y: 0, date: '' })
const ctxEl = ref(null)
const quick = reactive({ open: false, kind: 'todo', date: '', title: '', content: '', time: '09:00', projectId: 0, type: 'task', priority: 'medium', startAt: '', dueAt: '', busy: false })
const quickInput = ref(null)
const qmd = useMdEditor(quick, 'content', e => store.showToast({ type: 'error', text: String(e) }))
function onCellCtx(e, c) {
selected.value = c.date
ctx.date = c.date
ctx.x = e.clientX
ctx.y = e.clientY
ctx.open = true
// 渲染后按实际尺寸夹紧到视口内,避免贴边右键时菜单溢出
nextTick(() => {
const el = ctxEl.value
if (!el) return
const pad = 10
ctx.x = Math.max(pad, Math.min(e.clientX, window.innerWidth - el.offsetWidth - pad))
ctx.y = Math.max(pad, Math.min(e.clientY, window.innerHeight - el.offsetHeight - pad))
})
}
function openQuick(kind) {
ctx.open = false
quick.kind = kind
quick.date = ctx.date
quick.title = ''
quick.content = ''
quick.time = '09:00'
quick.type = 'task'
quick.priority = kind === 'reminder' ? 'high' : 'medium'
quick.startAt = ctx.date
quick.dueAt = kind === 'ticket' ? ctx.date : `${ctx.date}T18:00`
quick.projectId = kind === 'ticket' ? (store.projects[0]?.id || 0) : 0
qmd.reset()
quick.open = true
nextTick(() => quickInput.value?.focus())
}
async function createQuick() {
const title = quick.title.trim()
if (!title || quick.busy) return
quick.busy = true
try {
if (quick.kind === 'ticket') {
await call('SaveTicket', { id: 0, title, description: quick.content, type: quick.type, projectId: quick.projectId, startAt: quick.startAt, dueAt: quick.dueAt, priority: quick.priority, status: 'open' })
} else if (quick.kind === 'reminder') {
// reminder = 到点提醒的高优先级待办,复用现有提醒循环与云同步
await call('SaveTodo', { id: 0, title, content: '', projectId: 0, dueAt: `${quick.date}T${quick.time}`, priority: 'high', status: 'open' })
} else {
await call('SaveTodo', { id: 0, title, content: quick.content, projectId: quick.projectId, dueAt: quick.dueAt, priority: quick.priority, status: 'open' })
}
quick.open = false
store.showToast({ type: 'success', key: 'quickCreated' })
await load()
notifyTasksChanged()
} catch (e) {
store.showToast({ type: 'error', text: String(e) })
} finally {
quick.busy = false
}
}
function closeCtx() { ctx.open = false }
function onGlobalKey(e) {
if (e.key === 'Escape') { ctx.open = false; quick.open = false; festAdmin.value = false }
}
// ---- 待办/工单详情:复用 TaskDetailModal日历内直接查看与处理不跳转页面 ----
const detail = reactive({ open: false, kind: 'todo', item: null })
function openDetail(kind, x) {
detail.kind = kind
detail.item = x
detail.open = true
}
function move(delta) {
cursor.value = new Date(cursor.value.getFullYear(), cursor.value.getMonth() + delta, 1)
}
async function load() {
;[todos.value, tickets.value] = await Promise.all([call('ListTodos', 'all', 0), call('ListTickets', 'all', 0)])
}
const offTasks = onTasksChanged(load)
onMounted(async () => {
addEventListener('click', closeCtx)
addEventListener('keydown', onGlobalKey)
addEventListener('mousedown', onDocDown)
if (!store.projects.length) store.refresh().catch(() => {})
await load()
selected.value = todayStr
})
onUnmounted(() => {
removeEventListener('click', closeCtx)
removeEventListener('keydown', onGlobalKey)
removeEventListener('mousedown', onDocDown)
offTasks()
})
</script>
<template>
<div class="page calendar-page">
<header class="page-head sticky-head calendar-head">
<div><h1>{{ t('calendar') }}</h1><p>{{ t('calendarSubtitle') }}</p></div>
<div class="actions calendar-nav">
<div ref="calSwitchEl" class="cal-switch">
<button type="button" class="cal-nav-btn" @click="move(-1)"><ChevronLeft /></button>
<div class="cal-ym">
<button type="button" :class="{ on: ymPick === 'year' }" @click="openYm('year')">{{ dispYear }}</button>
<button type="button" :class="{ on: ymPick === 'month' }" @click="openYm('month')">{{ dispMonth }}</button>
</div>
<button type="button" class="cal-nav-btn" @click="move(1)"><ChevronRight /></button>
<div v-if="ymPick" class="cal-pop popover-glass">
<header v-if="ymPick === 'year'" class="cal-pop-head">
<button type="button" @click="yearBase -= 12"><ChevronLeft /></button>
<b>{{ yearWindow[0] }} - {{ yearWindow[11] }}</b>
<button type="button" @click="yearBase += 12"><ChevronRight /></button>
</header>
<div class="cal-pop-grid">
<template v-if="ymPick === 'year'">
<button v-for="y in yearWindow" :key="y" type="button" :class="{ active: y === cursor.getFullYear() }" @click="setYear(y)">{{ y }}</button>
</template>
<template v-else>
<button v-for="(m, i) in monthNames" :key="m" type="button" :class="{ active: i === cursor.getMonth() }" @click="setMonth(i)">{{ m }}</button>
</template>
</div>
</div>
</div>
<div class="cal-jump" :title="t('calJumpTitle')">
<ArrowRight />
<input v-model="jumpVal" :placeholder="t('calJumpPh')" @keydown.enter="jumpTo" />
</div>
<button class="btn secondary" @click="cursor = new Date(); selected = todayStr">{{ t('today') }}</button>
<button class="btn secondary daily-open-btn" @click="store.dailyCardDate = todayStr"><Sparkles />{{ t('dailyTodayBtn') }}</button>
<button class="btn secondary" @click="aiOpen = true"><Sparkles />{{ t('aiScopeBtn') }}</button>
<button v-if="isAdmin && selectedCell && selectedCell.lunar && selectedCell.lunar.tags.length" class="btn secondary fest-admin-btn" :title="t('festImgTitle')" @click="festAdmin = true"><ImagePlus /></button>
</div>
</header>
<div class="calendar-layout">
<section class="panel calendar-grid-panel">
<div class="calendar-weekdays"><span v-for="d in [t('weekdays.sun'), t('weekdays.mon'), t('weekdays.tue'), t('weekdays.wed'), t('weekdays.thu'), t('weekdays.fri'), t('weekdays.sat')]" :key="d">{{ d }}</span></div>
<div class="calendar-grid">
<button v-for="c in grid" :key="c.date" class="calendar-cell" :class="[{ out: !c.inMonth, today: c.today, selected: c.date === selected }, cellArt(c)?.cls]"
:style="cellArt(c)?.style"
@click="onCellClick(c)" @contextmenu.prevent="onCellCtx($event, c)">
<span class="calendar-head-row">
<span class="calendar-day">{{ c.day }}</span>
<small v-if="c.lunar" class="calendar-lunar">{{ c.lunar.label }}</small>
</span>
<small v-if="c.lunar && c.lunar.festLabel" class="calendar-fest" :class="{ jieqi: c.lunar.jieqi }">{{ c.lunar.festLabel }}</small>
<div class="calendar-marks">
<i v-for="x in c.todos.slice(0, 3)" :key="'t' + x.id" class="mark-todo" :class="x.priority" :title="x.title" />
<em v-for="x in c.tickets.slice(0, 2)" :key="'k' + x.id" class="mark-ticket" :class="[x.status, { start: x.isStart, end: x.isEnd }]" :title="x.title">{{ x.isStart ? x.title : '' }}</em>
<small v-if="c.todos.length + c.tickets.length > 5">+{{ c.todos.length + c.tickets.length - 5 }}</small>
</div>
</button>
</div>
</section>
<section class="panel calendar-detail-panel">
<h2><CalendarDays class="panel-icon" />{{ selected || t('selectDate') }}</h2>
<template v-if="selectedCell">
<div v-if="selectedCell.lunar" class="calendar-lunar-card" :class="{ festive: !!selectedCell.lunar.egg }">
<i v-if="selectedCell.lunar.egg" class="calendar-egg-vec" aria-hidden="true" :style="{ backgroundImage: eggBg(selectedCell.lunar.egg) }" />
<b>{{ selectedCell.lunar.full }}</b>
<div v-if="selectedCell.lunar.tags.length" class="calendar-fest-tags">
<span v-for="f in selectedCell.lunar.tags" :key="f">{{ f }}</span>
</div>
</div>
<button class="daily-entry" :disabled="selected > todayStr" :title="selected > todayStr ? t('dailyFuture') : ''" @click="store.dailyCardDate = selected">
<Sparkles />
<span>{{ selected === todayStr ? t('dailyTodayBtn') : t('dailyViewBtn') }}</span>
<ArrowRight class="daily-entry-arrow" />
</button>
<div class="calendar-group" v-if="selectedCell.todos.length">
<h3><ListTodo />{{ t('todos') }}</h3>
<button v-for="x in selectedCell.todos" :key="x.id" class="calendar-item" :class="x.priority" @click="openDetail('todo', x)">
<b>{{ x.title }}</b>
<span class="todo-priority" :class="x.priority">{{ t('priority.' + x.priority) }}</span>
<ArrowRight />
</button>
</div>
<div class="calendar-group" v-if="selectedCell.tickets.length">
<h3><TicketCheck />{{ t('tickets') }}</h3>
<button v-for="x in selectedCell.tickets" :key="x.id" class="calendar-item" :class="x.priority" @click="openDetail('ticket', x)">
<b>{{ x.title }}</b>
<span class="ticket-status" :class="x.status">{{ t('ticketStatus.' + x.status) }}</span>
<ArrowRight />
</button>
</div>
<div v-if="!selectedCell.todos.length && !selectedCell.tickets.length" class="empty">{{ t('noSchedule') }}</div>
</template>
</section>
</div>
<!-- 右键菜单 / 快速创建Teleport body避免 .page 的入场动画成为 fixed 定位包含块导致偏移 -->
<Teleport to="body">
<div v-if="ctx.open" ref="ctxEl" class="calendar-ctx popover-glass" :style="{ left: ctx.x + 'px', top: ctx.y + 'px' }" @click.stop>
<small>{{ ctx.date }}</small>
<button @click="openQuick('todo')"><ListTodo />{{ t('ctxNewTodo') }}</button>
<button :disabled="!store.projects.length" :title="store.projects.length ? '' : t('ctxNeedProject')" @click="openQuick('ticket')"><TicketCheck />{{ t('ctxNewTicket') }}</button>
<button @click="openQuick('reminder')"><AlarmClock />{{ t('ctxNewReminder') }}</button>
</div>
<div v-if="festAdmin && selectedCell && selectedCell.lunar" class="overlay" @click.self="!festBusy && (festAdmin = false)">
<section class="modal fest-admin-modal" @click.stop>
<header>
<h2><ImagePlus class="panel-icon" />{{ t('festImgTitle') }}<span class="quick-date">{{ selected }}</span></h2>
<button type="button" :disabled="festBusy" @click="festAdmin = false"><X /></button>
</header>
<div class="fest-admin-body">
<div v-for="tg in selectedCell.lunar.tags" :key="tg" class="fest-style-group">
<div class="fest-style-head">
<span class="fest-style-name">{{ tg }}</span>
<span v-if="hasFestImg(tg)" class="fest-style-ops">
<button class="fest-img-btn" :disabled="festBusy" @click="pickFestImg(tg)">{{ t('festImgReplace') }}</button>
<button class="fest-img-btn danger" :disabled="festBusy" @click="removeFestImg(tg)"><X />{{ t('festImgRemove') }}</button>
</span>
</div>
<div class="fest-style-cards">
<button class="fest-card" :class="{ active: festModeOf(tg) === 'art' }" :disabled="festBusy" @click="chooseFestArt(tg)">
<span class="fest-card-preview" :style="selectedCell.lunar.egg ? { backgroundImage: eggArt(selectedCell.lunar.egg) } : null"></span>
<span class="fest-card-label"><Sparkles />{{ t('festModeArtCard') }}</span>
<span v-if="festModeOf(tg) === 'art'" class="fest-card-check"><Check /></span>
</button>
<button class="fest-card" :class="{ active: festModeOf(tg) === 'photo' }" :disabled="festBusy" @click="chooseFestPhoto(tg)">
<span class="fest-card-preview photo" :style="festPhotoStyle(tg)">
<span v-if="!hasFestImg(tg)" class="fest-card-upload"><ImagePlus />{{ t('festImgSet') }}</span>
</span>
<span class="fest-card-label"><ImageIcon />{{ t('festModePhotoCard') }}</span>
<span v-if="festModeOf(tg) === 'photo'" class="fest-card-check"><Check /></span>
</button>
</div>
</div>
</div>
</section>
</div>
<TaskDetailModal v-if="detail.open && detail.item" :kind="detail.kind" :item="detail.item" @close="detail.open = false" @changed="load" />
<div v-if="quick.open" class="overlay" @click.self="!quick.busy && (quick.open = false)">
<!-- 提醒仅标题+时间两个字段保留紧凑框待办/工单与对应页面的分栏模态框完全对齐 -->
<section v-if="quick.kind === 'reminder'" class="modal quick-modal" @click.stop>
<button class="login-close" :aria-label="t('close')" @click="quick.open = false"><X /></button>
<h2>
<AlarmClock class="panel-icon" />
{{ t('ctxNewReminder') }}
<small class="quick-date">{{ quick.date }}</small>
</h2>
<div class="quick-form">
<input ref="quickInput" v-model="quick.title" :placeholder="t('quickTitlePh')" @keyup.enter="createQuick" />
<input v-model="quick.time" type="time" />
<button class="btn primary" :disabled="!quick.title.trim() || quick.busy" @click="createQuick"><Plus />{{ t('quickCreate') }}</button>
</div>
</section>
<form v-else class="modal modal-split" @click.stop @submit.prevent="createQuick">
<header>
<h2>
{{ t(quick.kind === 'ticket' ? 'ctxNewTicket' : 'ctxNewTodo') }}
<small class="quick-date">{{ quick.date }}</small>
</h2>
<button type="button" :disabled="quick.busy" @click="quick.open = false"><X /></button>
</header>
<div class="split-body">
<div class="split-fields">
<label>{{ t(quick.kind === 'ticket' ? 'ticketTitle' : 'todoTitle') }}<input ref="quickInput" v-model="quick.title" :disabled="quick.busy" required /></label>
<label v-if="quick.kind === 'ticket'">{{ t('relatedProject') }} *<select v-model.number="quick.projectId" :disabled="quick.busy" required><option :value="0" disabled>{{ t('selectProject') }}</option><option v-for="p in store.projects" :key="p.id" :value="p.id">{{ p.name }}</option></select></label>
<label v-else>{{ t('relatedProject') }}<select v-model.number="quick.projectId" :disabled="quick.busy"><option :value="0">{{ t('noProject') }}</option><option v-for="p in store.projects" :key="p.id" :value="p.id">{{ p.name }}</option></select></label>
<label v-if="quick.kind === 'ticket'">{{ t('ticketTypeLabel') }}<select v-model="quick.type" :disabled="quick.busy"><option v-for="k in ['feature', 'bug', 'task', 'improvement']" :key="k" :value="k">{{ t('ticketType.' + k) }}</option></select></label>
<label v-if="quick.kind === 'ticket'">{{ t('startDate') }}<DatePicker v-model="quick.startAt" :disabled="quick.busy" :clearable="false" /></label>
<label>{{ t('dueDate') }}
<DatePicker v-if="quick.kind === 'ticket'" v-model="quick.dueAt" :disabled="quick.busy" :clearable="false" />
<input v-else v-model="quick.dueAt" type="datetime-local" :disabled="quick.busy" />
<DueQuickPick v-model="quick.dueAt" :with-time="quick.kind !== 'ticket'" :disabled="quick.busy" />
</label>
<label>{{ t('priorityLabel') }}<select v-model="quick.priority" :disabled="quick.busy"><option value="low">{{ t('priority.low') }}</option><option value="medium">{{ t('priority.medium') }}</option><option value="high">{{ t('priority.high') }}</option></select></label>
</div>
<div class="split-editor">
<div class="md-toolbar">
<span class="md-field-label">{{ t(quick.kind === 'ticket' ? 'ticketDesc' : 'todoContent') }}</span>
<div class="tabs compact">
<button type="button" :class="{ active: !qmd.preview.value }" @click="qmd.preview.value = false">{{ t('mdEdit') }}</button>
<button type="button" :class="{ active: qmd.preview.value }" @click="qmd.preview.value = true">{{ t('mdPreviewTab') }}</button>
</div>
<button type="button" class="btn secondary md-img-btn" :disabled="quick.busy || qmd.uploading.value" @click="qmd.pickImage"><ImagePlus />{{ qmd.uploading.value ? t('mdInserting') : t('insertImage') }}</button>
</div>
<div class="md-editor">
<textarea v-show="!qmd.preview.value" :ref="qmd.inputEl" v-model="quick.content" :disabled="quick.busy" :placeholder="t('mdPlaceholder')" @paste="qmd.onPaste" />
<MarkdownView v-if="qmd.preview.value" class="md-preview-box" :source="quick.content || t('mdEmpty')" />
</div>
</div>
</div>
<footer>
<button type="button" class="btn secondary" :disabled="quick.busy" @click="quick.open = false">{{ t('cancel') }}</button>
<button class="btn primary" :disabled="!quick.title.trim() || quick.busy"><Plus />{{ quick.busy ? t('saving') : t('quickCreate') }}</button>
</footer>
</form>
</div>
</Teleport>
<AIScopeDrawer v-if="aiOpen" kind="calendar" :title="t('calendar')" @close="aiOpen = false" />
</div>
</template>

View File

@@ -0,0 +1,237 @@
<script setup>
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { Rocket, Plus, RefreshCw, Play, Square, Pencil, Trash2, Pin, FolderOpen, X, Hexagon, Zap, FileCode2, Coffee, Database, Globe, Boxes, Box, Cpu, MemoryStick, HardDrive, Sparkles } from 'lucide-vue-next'
import { call, on } from '../api'
import AIScopeDrawer from '../components/AIScopeDrawer.vue'
// 启动台:扫描本机监听端口的服务 + 管理保存的应用(启动/停止/资源占用)。
const { t } = useI18n()
const aiOpen = ref(false)
const entries = ref([])
const loading = ref(false)
const showSys = ref(localStorage.getItem('cc-lp-sys') === '1')
const editing = ref(null) // 编辑/新建表单数据
const editErr = ref('')
const suggest = ref({ start: [], stop: [] })
const busy = ref({}) // id/pid -> true 启停按钮防抖
let timer = 0
let offChanged = null
const KINDS = ['node', 'go', 'python', 'java', 'php', 'dotnet', 'mysql', 'redis', 'nginx', 'web', 'other']
const KIND_UI = {
node: { icon: Hexagon, color: '#8cc84b' },
go: { icon: Zap, color: '#00add8' },
python: { icon: FileCode2, color: '#ffd343' },
java: { icon: Coffee, color: '#f89820' },
php: { icon: FileCode2, color: '#a78bfa' },
dotnet: { icon: Boxes, color: '#8b5cf6' },
mysql: { icon: Database, color: '#4f9df5' },
redis: { icon: Database, color: '#f16a5b' },
nginx: { icon: Globe, color: '#26c795' },
web: { icon: Globe, color: '#4f9df5' },
other: { icon: Box, color: 'var(--muted)' }
}
const kindUI = k => KIND_UI[k] || KIND_UI.other
// Windows 关键系统进程:默认隐藏,避免误停
const SYS_NAMES = ['system', 'svchost.exe', 'lsass.exe', 'wininit.exe', 'services.exe', 'csrss.exe', 'winlogon.exe', 'spoolsv.exe', 'searchindexer.exe', 'memcompression', 'registry']
const isSys = x => !x.id && (SYS_NAMES.includes((x.name || '').toLowerCase()) || /^PID \d+$/.test(x.name || ''))
const myApps = computed(() => entries.value.filter(x => x.id > 0))
const scanned = computed(() => entries.value.filter(x => !x.id && (showSys.value || !isSys(x))))
const hiddenCount = computed(() => entries.value.filter(x => !x.id && isSys(x)).length)
async function load() {
loading.value = true
try { entries.value = await call('ListLaunchEntries') } catch { /* 后端未就绪 */ }
loading.value = false
}
function toggleSys() {
showSys.value = !showSys.value
localStorage.setItem('cc-lp-sys', showSys.value ? '1' : '0')
}
// ---- 添加 / 编辑 ----
async function openForm(x) {
editErr.value = ''
editing.value = x
? { id: x.id || 0, name: x.name || '', kind: x.kind || 'other', port: x.port || x.ports?.[0] || 0, dir: x.dir || '', startCmd: x.startCmd || '', stopCmd: x.stopCmd || '' }
: { id: 0, name: '', kind: 'other', port: 0, dir: '', startCmd: '', stopCmd: '' }
await loadSuggest()
}
async function loadSuggest() {
try { suggest.value = await call('LaunchCmdSuggest', editing.value.kind) } catch { suggest.value = { start: [], stop: [] } }
}
async function pickDir() {
try {
const d = await call('SelectDirectory')
if (d) editing.value.dir = d
} catch { /* 用户取消 */ }
}
async function saveForm() {
editErr.value = ''
try {
await call('SaveLaunchApp', { ...editing.value, port: Number(editing.value.port) || 0 })
editing.value = null
await load()
} catch (e) {
editErr.value = String(e?.message || e)
}
}
async function removeApp(x) {
if (!confirm(t('lpDelConfirm', { name: x.name }))) return
await call('DeleteLaunchApp', x.id)
await load()
}
// ---- 启动 / 停止 ----
async function start(x) {
if (!x.startCmd) {
await openForm(x)
editErr.value = t('lpNeedCmd')
return
}
busy.value[x.id] = true
try { await call('StartLaunchApp', x.id) } catch (e) {
if (String(e?.message || e).includes('LAUNCH_CMD_REQUIRED')) { await openForm(x); editErr.value = t('lpNeedCmd') }
}
busy.value[x.id] = false
setTimeout(load, 800) // 给进程一点起监听的时间
}
async function stop(x) {
if (!confirm(t('lpStopConfirm', { name: x.name }))) return
const key = x.id || x.pid
busy.value[key] = true
try { await call('StopLaunchApp', x.id || 0, x.pid || 0) } catch { /* 已记录日志 */ }
busy.value[key] = false
setTimeout(load, 500)
}
const fmtCPU = v => (v >= 10 ? v.toFixed(0) : v.toFixed(1)) + '%'
const fmtMem = v => v >= 1024 ? (v / 1024).toFixed(1) + ' GB' : v.toFixed(0) + ' MB'
const fmtIO = v => v >= 1024 ? (v / 1024).toFixed(1) + ' MB/s' : v.toFixed(0) + ' KB/s'
onMounted(() => {
load()
timer = setInterval(load, 5000)
offChanged = on('launchpad:changed', load)
})
onUnmounted(() => { clearInterval(timer); offChanged?.() })
</script>
<template>
<div class="page launchpad-page">
<header class="page-head sticky-head">
<div><h1>{{ t('launchpad') }}</h1><p>{{ t('launchpadSubtitle') }}</p></div>
<div class="lp-tools">
<label class="lp-sys-toggle"><input type="checkbox" :checked="showSys" @change="toggleSys" />{{ t('lpShowSys') }}<em v-if="hiddenCount && !showSys">{{ hiddenCount }}</em></label>
<button class="btn secondary" @click="aiOpen = true"><Sparkles />{{ t('aiScopeBtn') }}</button>
<button class="btn secondary" :disabled="loading" @click="load"><RefreshCw :class="{ spinning: loading }" />{{ t('lpRefresh') }}</button>
<button class="btn" @click="openForm(null)"><Plus />{{ t('lpAddApp') }}</button>
</div>
</header>
<section class="lp-section">
<h2 class="lp-title"><Pin />{{ t('lpMyApps') }}<em>{{ myApps.length }}</em></h2>
<div v-if="!myApps.length" class="lp-empty">{{ t('lpEmptyApps') }}</div>
<div v-else class="lp-grid">
<article v-for="x in myApps" :key="'a' + x.id" class="card lp-card" :class="{ running: x.running }">
<header>
<span class="lp-icon" :style="{ color: kindUI(x.kind).color, background: `color-mix(in srgb, ${kindUI(x.kind).color} 14%, transparent)` }"><component :is="kindUI(x.kind).icon" /></span>
<div class="lp-name"><b :title="x.exe || x.name">{{ x.name }}</b><small>{{ x.kind }}</small></div>
<i class="lp-dot" :class="{ on: x.running }" :title="x.running ? t('lpRunning') : t('lpStopped')" />
</header>
<div class="lp-ports">
<span v-for="p in (x.ports?.length ? x.ports : (x.port ? [x.port] : [])).slice(0, 4)" :key="p" class="lp-port">:{{ p }}</span>
<span v-if="(x.ports?.length || 0) > 4" class="lp-port more">+{{ x.ports.length - 4 }}</span>
<small v-if="x.pid" class="lp-pid">PID {{ x.pid }}</small>
</div>
<div v-if="x.running" class="lp-res">
<span :title="'CPU'"><Cpu />{{ fmtCPU(x.cpu) }}</span>
<span :title="t('lpMem')"><MemoryStick />{{ fmtMem(x.memMB) }}</span>
<span :title="'IO'"><HardDrive />{{ fmtIO(x.ioKBs) }}</span>
</div>
<p v-if="x.dir || x.cmdline" class="lp-meta" :title="x.cmdline || x.dir">{{ x.dir || x.cmdline }}</p>
<footer>
<button v-if="!x.running" class="lp-act go" :disabled="busy[x.id]" @click="start(x)"><Play />{{ t('lpStart') }}</button>
<button v-else class="lp-act halt" :disabled="busy[x.id]" @click="stop(x)"><Square />{{ t('lpStop') }}</button>
<span class="lp-gap" />
<button class="lp-act" :title="t('edit')" @click="openForm(x)"><Pencil /></button>
<button class="lp-act danger" :title="t('delete')" @click="removeApp(x)"><Trash2 /></button>
</footer>
</article>
</div>
</section>
<section class="lp-section">
<h2 class="lp-title"><Rocket />{{ t('lpScanned') }}<em>{{ scanned.length }}</em></h2>
<div v-if="!scanned.length" class="lp-empty">{{ t('lpEmptyScan') }}</div>
<div v-else class="lp-grid">
<article v-for="x in scanned" :key="'p' + x.pid" class="card lp-card running">
<header>
<span class="lp-icon" :style="{ color: kindUI(x.kind).color, background: `color-mix(in srgb, ${kindUI(x.kind).color} 14%, transparent)` }"><component :is="kindUI(x.kind).icon" /></span>
<div class="lp-name"><b :title="x.exe || x.name">{{ x.name }}</b><small>{{ x.kind }} · PID {{ x.pid }}</small></div>
<i class="lp-dot on" :title="t('lpRunning')" />
</header>
<div class="lp-ports">
<span v-for="p in x.ports.slice(0, 4)" :key="p" class="lp-port">:{{ p }}</span>
<span v-if="x.ports.length > 4" class="lp-port more">+{{ x.ports.length - 4 }}</span>
</div>
<div class="lp-res">
<span :title="'CPU'"><Cpu />{{ fmtCPU(x.cpu) }}</span>
<span :title="t('lpMem')"><MemoryStick />{{ fmtMem(x.memMB) }}</span>
<span :title="'IO'"><HardDrive />{{ fmtIO(x.ioKBs) }}</span>
</div>
<p v-if="x.cmdline || x.exe" class="lp-meta" :title="x.cmdline || x.exe">{{ x.cmdline || x.exe }}</p>
<footer>
<button class="lp-act" @click="openForm(x)"><Pin />{{ t('lpPin') }}</button>
<span class="lp-gap" />
<button class="lp-act halt" :disabled="busy[x.pid]" @click="stop(x)"><Square />{{ t('lpStop') }}</button>
</footer>
</article>
</div>
</section>
<Teleport to="body">
<div v-if="editing" class="overlay" @click.self="editing = null">
<section class="modal lp-modal">
<header class="lp-modal-head">
<h2><Rocket />{{ editing.id ? t('lpEditApp') : t('lpAddApp') }}</h2>
<button type="button" class="nm-close" :title="t('close')" @click="editing = null"><X /></button>
</header>
<div class="lp-form">
<label class="lp-field"><span>{{ t('lpName') }}</span><input v-model="editing.name" :placeholder="t('lpName')" /></label>
<div class="lp-row">
<label class="lp-field"><span>{{ t('lpKind') }}</span>
<select v-model="editing.kind" @change="loadSuggest">
<option v-for="k in KINDS" :key="k" :value="k">{{ k }}</option>
</select>
</label>
<label class="lp-field"><span>{{ t('lpPort') }}</span><input v-model="editing.port" type="number" min="0" max="65535" /></label>
</div>
<label class="lp-field"><span>{{ t('lpDir') }}</span>
<span class="lp-dir-row"><input v-model="editing.dir" :placeholder="t('lpDir')" /><button type="button" class="btn secondary" @click="pickDir"><FolderOpen /></button></span>
</label>
<label class="lp-field"><span>{{ t('lpStartCmd') }}</span><input v-model="editing.startCmd" placeholder="npm run dev" /></label>
<div v-if="suggest.start?.length" class="lp-suggest">
<small>{{ t('lpSuggest') }}</small>
<button v-for="c in suggest.start" :key="c" type="button" @click="editing.startCmd = c">{{ c }}</button>
</div>
<label class="lp-field"><span>{{ t('lpStopCmd') }}</span><input v-model="editing.stopCmd" :placeholder="t('lpStopBlank')" /></label>
<div v-if="suggest.stop?.length" class="lp-suggest">
<small>{{ t('lpSuggest') }}</small>
<button v-for="c in suggest.stop" :key="c" type="button" @click="editing.stopCmd = c">{{ c }}</button>
</div>
<p v-if="editErr" class="lp-err">{{ editErr }}</p>
</div>
<footer class="lp-modal-foot">
<button class="btn secondary" @click="editing = null">{{ t('cancel') }}</button>
<button class="btn" @click="saveForm">{{ t('save') }}</button>
</footer>
</section>
</div>
</Teleport>
<AIScopeDrawer v-if="aiOpen" kind="launchpad" :title="t('launchpad')" @close="aiOpen = false" />
</div>
</template>

View File

@@ -0,0 +1,82 @@
<script setup>
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { Bell, CheckCheck, Trash2, ListTodo, TicketCheck, BarChart3, RefreshCw, Info } from 'lucide-vue-next'
import { call, on } from '../api'
import { useAppStore } from '../store'
// 消息中心页:全部消息 + 分类/未读筛选,与顶部铃铛下拉共用视觉语言。
const router = useRouter()
const store = useAppStore()
const { t } = useI18n()
const items = ref([])
const kind = ref('all')
const unreadOnly = ref(false)
const kindIcon = { todo_due: ListTodo, ticket_due: TicketCheck, analysis: BarChart3, sync: RefreshCw }
const KINDS = ['all', 'todo_due', 'ticket_due', 'analysis', 'sync']
let offNew = null
async function load() {
try { items.value = await call('ListMessages', 500) } catch { /* 后端未就绪时静默 */ }
}
const countOf = k => k === 'all' ? items.value.length : items.value.filter(m => m.kind === k).length
const shown = computed(() => items.value.filter(m =>
(kind.value === 'all' || m.kind === kind.value) && (!unreadOnly.value || !m.read)))
async function openItem(m) {
if (!m.read) {
try { await call('MarkMessageRead', m.id) } catch { return }
m.read = true
store.refreshUnread()
}
if (m.sourceType === 'todo') router.push('/todos')
else if (m.sourceType === 'ticket') router.push('/tickets')
else if (m.sourceType === 'project' && m.sourceId) router.push(`/project/${m.sourceId}`)
}
async function markAll() {
await call('MarkAllMessagesRead')
items.value.forEach(m => { m.read = true })
store.refreshUnread()
}
async function clearAll() {
if (!confirm(t('clearMessages') + '?')) return
await call('ClearMessages')
items.value = []
store.refreshUnread()
}
onMounted(() => { load(); offNew = on('message:new', load) })
onUnmounted(() => offNew?.())
</script>
<template>
<div class="page msg-page">
<header class="page-head sticky-head">
<div><h1>{{ t('messages') }}</h1><p>{{ t('messagesSubtitle') }}</p></div>
<div class="actions">
<label class="msg-unread-toggle"><input v-model="unreadOnly" type="checkbox" />{{ t('msgUnreadOnly') }}</label>
<button class="btn secondary" @click="markAll"><CheckCheck />{{ t('markAllRead') }}</button>
<button class="btn secondary" @click="clearAll"><Trash2 />{{ t('clearMessages') }}</button>
</div>
</header>
<div class="msg-chips">
<button v-for="k in KINDS" :key="k" type="button" class="msg-chip" :class="{ active: kind === k }" @click="kind = k">
{{ k === 'all' ? t('msgKindAll') : t('msgKind.' + k) }}<em>{{ countOf(k) }}</em>
</button>
</div>
<section class="panel msg-panel">
<button v-for="m in shown" :key="m.id" class="bell-item msg-row" :class="{ unread: !m.read }" @click="openItem(m)">
<span class="bell-icon" :class="m.kind"><component :is="kindIcon[m.kind] || Info" /></span>
<div>
<b>{{ m.title }}</b>
<p v-if="m.body">{{ m.body }}</p>
<time>{{ m.createdAt?.replace('T', ' ').slice(0, 19) }}</time>
</div>
<i v-if="!m.read" class="bell-dot" />
</button>
<div v-if="!shown.length" class="bell-empty msg-empty"><Bell />{{ t('noMessages') }}</div>
</section>
</div>
</template>

View File

@@ -0,0 +1,66 @@
<script setup>
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { StickyNote, Plus, Search, Trash2, Sparkles } from 'lucide-vue-next'
import { call } from '../api'
import NoteModal from '../components/NoteModal.vue'
import AIScopeDrawer from '../components/AIScopeDrawer.vue'
// 笔记页:全部笔记卡片网格,点卡片进入编辑模态框。
const { t } = useI18n()
const notes = ref([])
const query = ref('')
const editing = ref(null) // null=关闭;{} = 新建;{id,...} = 编辑
const aiOpen = ref(false)
async function load() {
try { notes.value = await call('ListNotes', 500) } catch { /* 后端未就绪时静默 */ }
}
const shown = computed(() => {
const q = query.value.trim().toLowerCase()
return q ? notes.value.filter(n => (n.content || '').toLowerCase().includes(q)) : notes.value
})
const title = n => {
const line = (n.content || '').split('\n').find(l => l.trim()) || ''
return line.trim().replace(/^#+\s*/, '') || t('noteUntitled')
}
const preview = n => {
const lines = (n.content || '').split('\n')
const idx = lines.findIndex(l => l.trim())
return lines.slice(idx + 1).filter(l => l.trim()).join(' ').slice(0, 160)
}
const fullTime = s => String(s || '').replace('T', ' ').slice(0, 19)
async function removeNote(n) {
if (!confirm(t('noteDeleteConfirm'))) return
try { await call('DeleteNote', n.id) } catch { return }
await load()
}
onMounted(load)
</script>
<template>
<div class="page notes-page">
<header class="page-head sticky-head">
<div><h1>{{ t('notesPage') }}</h1><p>{{ t('notesSubtitle', { n: notes.length }) }}</p></div>
<div class="actions">
<label class="note-search"><Search /><input v-model="query" :placeholder="t('noteSearchPh')" /></label>
<button class="btn secondary" @click="aiOpen = true"><Sparkles />{{ t('aiScopeBtn') }}</button>
<button class="btn" @click="editing = {}"><Plus />{{ t('noteNew') }}</button>
</div>
</header>
<div v-if="!shown.length" class="today-empty"><StickyNote />{{ t('noteEmpty') }}</div>
<div v-else class="notes-grid">
<article v-for="n in shown" :key="n.id" class="card note-card" @click="editing = n">
<b class="note-card-title">{{ title(n) }}</b>
<p v-if="preview(n)" class="note-card-preview">{{ preview(n) }}</p>
<footer>
<time>{{ fullTime(n.updatedAt) }}</time>
<button type="button" class="note-card-del" :title="t('delete')" @click.stop="removeNote(n)"><Trash2 /></button>
</footer>
</article>
</div>
<NoteModal v-if="editing" :note="editing" @close="editing = null" @changed="load" />
<AIScopeDrawer v-if="aiOpen" kind="notes" :title="t('notesPage')" @close="aiOpen = false" />
</div>
</template>

View File

@@ -0,0 +1,515 @@
<script setup>
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { UserRound, ImageUp, KeyRound, CloudUpload, LogIn, LogOut, RefreshCw, Wifi, WifiOff, Info, ShieldCheck, Images, Camera, X, Lock, Folder, CircleCheck, TicketCheck, Star, ListTodo, Settings as SettingsIcon, FileText, CalendarClock, UploadCloud, IdCard, Users, Plus, Check, ArrowRight, Tags, Copy, Trash2 } from 'lucide-vue-next'
import { call, isNative } from '../api'
import { useAppStore } from '../store'
import { teams, teamsErr, teamsLoading, loadTeams as loadTeamsShared, switchTeam as switchTeamShared } from '../team'
const store = useAppStore(), { t } = useI18n(), native = isNative()
const router = useRouter()
const form = reactive({ avatarMode: '', avatarValue: '', imageMode: 'base64' })
const pwdForm = reactive({ old: '', next: '', confirm: '' })
const busy = ref(''), msg = ref('')
const loaded = ref(false)
const avatarOpen = ref(false)
const TABS = ['info', 'teams', 'assets', 'security', 'sync']
const tab = ref(TABS.includes(localStorage.getItem('cc-profile-tab')) ? localStorage.getItem('cc-profile-tab') : 'info')
const doneTodos = ref(0)
const resolvedTickets = ref(0)
const sync = computed(() => store.syncStatus)
// ---- 全局文件存储(管理员在设置页配置;此处只读,决定素材库可用性与提示文案) ----
const fsCfg = reactive({ mode: 'local', baseUrl: '', apiKey: '' })
const serverStorage = computed(() => fsCfg.mode === 'server')
async function loadFileStorage() {
try {
const c = await call('GetFileStorageConfig')
Object.assign(fsCfg, { mode: c.mode || 'local', baseUrl: c.baseUrl || '', apiKey: c.apiKey || '' })
} catch {}
}
// ---- 素材库(服务器存储的图片管理:本人 / 团队管理员 / 超管三档范围) ----
const assets = ref([])
const assetsTotal = ref(0)
const assetsPage = ref(1)
const assetsScope = ref('mine')
const assetsTeamId = ref(0)
const assetsLoading = ref(false)
const assetsErr = ref('')
// 只有我担任 owner/admin 的团队才能看团队素材
const adminTeams = computed(() => teams.value.filter(x => ['owner', 'admin'].includes(x.role)))
async function loadAssets(reset = true) {
if (!serverStorage.value || !sync.value.loggedIn) return
if (reset) assetsPage.value = 1
assetsLoading.value = true; assetsErr.value = ''
try {
const tid = assetsScope.value === 'team' ? Number(assetsTeamId.value) : 0
const r = await call('ListServerFiles', assetsScope.value, tid, assetsPage.value)
assets.value = reset ? (r.items || []) : assets.value.concat(r.items || [])
assetsTotal.value = r.total || 0
} catch (e) {
assetsErr.value = errText(e)
if (reset) { assets.value = []; assetsTotal.value = 0 }
} finally { assetsLoading.value = false }
}
function reloadAssets() { loadAssets(true) }
function moreAssets() { assetsPage.value++; loadAssets(false) }
function onAssetsScope() {
if (assetsScope.value === 'team' && !assetsTeamId.value && adminTeams.value.length) assetsTeamId.value = adminTeams.value[0].id
reloadAssets()
}
async function deleteAsset(f) {
if (!confirm(t('assetsDeleteConfirm'))) return
try {
await call('DeleteServerFile', f.id)
assets.value = assets.value.filter(x => x.id !== f.id)
if (assetsTotal.value > 0) assetsTotal.value--
store.showToast({ type: 'success', key: 'assetsDeletedToast' })
} catch (e) { store.showToast({ type: 'error', text: errText(e) }) }
}
async function copyAsset(f) {
try {
await navigator.clipboard.writeText(f.url)
store.showToast({ type: 'success', key: 'assetsCopiedToast' })
} catch { store.showToast({ type: 'error', key: 'assetsCopyFailed' }) }
}
function fmtSize(n) {
if (n >= 1 << 20) return (n / (1 << 20)).toFixed(1) + ' MB'
if (n >= 1024) return Math.round(n / 1024) + ' KB'
return (n || 0) + ' B'
}
// ---- 个人资料(昵称/头衔/邮箱/简介/技术栈标签) ----
const profile = reactive({ nickname: '', title: '', email: '', bio: '', techTags: [] })
const tagInput = ref('')
const profileMsg = ref('')
async function loadProfile() {
try {
const p = await call('GetMyProfile')
Object.assign(profile, { nickname: p.nickname || '', title: p.title || '', email: p.email || '', bio: p.bio || '', techTags: p.techTags || [] })
} catch {}
}
function addTag() {
const v = tagInput.value.trim()
if (!v) return
if (!profile.techTags.some(x => x.toLowerCase() === v.toLowerCase())) profile.techTags.push(v)
tagInput.value = ''
}
function removeTag(i) { profile.techTags.splice(i, 1) }
async function saveProfile() {
if (busy.value) return
busy.value = 'profile'; profileMsg.value = ''
if (tagInput.value.trim()) addTag()
try {
const p = await call('SaveMyProfile', { ...profile })
Object.assign(profile, { nickname: p.nickname, title: p.title, email: p.email, bio: p.bio, techTags: p.techTags || [] })
store.showToast({ type: 'success', key: 'profileSavedToast' })
} catch (e) { profileMsg.value = errText(e) }
finally { busy.value = '' }
}
// ---- 我的团队(状态共享自 team.js切换会联动窗口标题 ----
const newTeamName = ref('')
async function loadTeams() {
if (!sync.value.loggedIn) return
await loadTeamsShared()
}
async function switchTeam(tm) {
if (tm.current) return
try { await switchTeamShared(tm.id) } catch (e) { store.showToast({ type: 'error', text: errText(e) }) }
}
async function createTeam() {
const name = newTeamName.value.trim()
if (!name || busy.value) return
busy.value = 'team'
try {
await call('TeamCreate', name)
newTeamName.value = ''
await loadTeams()
store.showToast({ type: 'success', key: 'teamCreatedToast' })
} catch (e) { store.showToast({ type: 'error', text: errText(e) }) }
finally { busy.value = '' }
}
function setTab(v) {
tab.value = v
localStorage.setItem('cc-profile-tab', v)
if (v === 'teams') loadTeams()
// 素材库可用性跟随全局配置,进入时先刷新配置再拉列表(管理员可能刚在设置页改过)
if (v === 'assets') { loadTeams(); loadFileStorage().then(() => reloadAssets()) }
}
// 按时段问候,让页面更像“我的空间”而不是后台表单
const greeting = computed(() => {
const h = new Date().getHours()
const key = h < 6 ? 'greetNight' : h < 11 ? 'greetMorning' : h < 14 ? 'greetNoon' : h < 18 ? 'greetAfternoon' : 'greetEvening'
return t(key)
})
// 新密码强度0 无 / 1 弱 / 2 中 / 3 强
const pwdStrength = computed(() => {
const v = pwdForm.next
if (!v) return 0
let s = v.length >= 6 ? 1 : 0
if (v.length >= 10) s++
if (/[A-Za-z]/.test(v) && /[0-9]/.test(v)) s++
if (/[^A-Za-z0-9]/.test(v)) s++
return Math.max(1, Math.min(3, s - (v.length < 6 ? 1 : 0)))
})
const strengthLabel = computed(() => [null, 'pwdWeak', 'pwdMedium', 'pwdStrong'][pwdStrength.value])
const errText = e => {
const code = String(e).split(':')[0].trim()
return t('errors.' + code) !== 'errors.' + code ? t('errors.' + code) : String(e)
}
// 后端存 UTCRFC3339展示时转为本地时区
const localSyncTime = computed(() => {
const s = sync.value.lastSyncAt
if (!s) return ''
const d = new Date(/[zZ]$|[+-]\d\d:?\d\d$/.test(s) ? s : s + 'Z')
if (isNaN(d.getTime())) return s.replace('T', ' ')
const p = n => String(n).padStart(2, '0')
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`
})
const scopeTags = [
{ icon: ListTodo, key: 'todos' },
{ icon: TicketCheck, key: 'tickets' },
{ icon: SettingsIcon, key: 'settings' },
{ icon: Star, key: 'scopeFavs' },
{ icon: Folder, key: 'projects' },
{ icon: FileText, key: 'scopeDocs' }
]
async function load() {
try {
const saved = await call('GetSettings')
form.avatarMode = saved.avatarMode || ''
form.avatarValue = saved.avatarValue || ''
form.imageMode = saved.imageMode || 'base64'
} catch {}
loaded.value = true
store.refreshSyncStatus()
try {
const [ts, ks] = await Promise.all([call('ListTodos', 'all', 0), call('ListTickets', 'all', 0)])
doneTodos.value = ts.filter(x => x.status === 'done').length
resolvedTickets.value = ks.filter(x => ['resolved', 'closed'].includes(x.status)).length
} catch {}
}
async function persist() {
if (!loaded.value) return
await store.saveSettings({ avatarMode: form.avatarMode, avatarValue: form.avatarValue, imageMode: form.imageMode })
}
watch(() => [form.avatarMode, form.avatarValue, form.imageMode], persist)
// 存储走向由后端按管理员全局配置实时决定autoserver 时上传返回 url否则 base64。
async function pickAvatar() {
try {
const r = await call('PickAvatarImage', 'auto')
if (r && r.value) {
form.avatarMode = r.mode || 'base64'
form.avatarValue = r.value
}
} catch (e) { store.showToast({ type: 'error', text: errText(e) }) }
}
function clearAvatar() { form.avatarMode = ''; form.avatarValue = '' }
// 打开弹窗时刷新全局配置,保证提示文案与实际走向一致
watch(avatarOpen, v => { if (v) loadFileStorage() })
async function syncNow() {
if (busy.value) return
busy.value = 'sync'; msg.value = ''
try {
const st = await call('SyncNow')
store.syncStatus = st
store.showToast({ type: 'success', key: 'syncDoneToast', params: { pushed: st.pushed, pulled: st.pulled } })
} catch (e) { msg.value = errText(e); store.showToast({ type: 'error', key: 'syncFailToast' }) }
finally { busy.value = '' }
}
async function syncLogout() {
await call('SyncLogout')
store.showToast({ type: 'success', key: 'logoutToast' })
await store.refreshSyncStatus()
}
async function changePassword() {
if (busy.value) return
if (pwdForm.next.length < 6) { msg.value = t('errors.SYNC_PASSWORD_TOO_SHORT'); return }
if (pwdForm.next !== pwdForm.confirm) { msg.value = t('passwordMismatch'); return }
busy.value = 'password'; msg.value = ''
try {
await call('SyncChangePassword', pwdForm.old, pwdForm.next)
pwdForm.old = ''; pwdForm.next = ''; pwdForm.confirm = ''
store.showToast({ type: 'success', key: 'passwordChangedToast' })
} catch (e) { msg.value = errText(e) }
finally { busy.value = '' }
}
function onKey(e) {
if (e.key === 'Escape') avatarOpen.value = false
}
onMounted(async () => {
load()
loadProfile()
await loadFileStorage()
if (tab.value === 'teams') loadTeams()
if (tab.value === 'assets') { loadTeams(); reloadAssets() }
addEventListener('keydown', onKey)
})
onUnmounted(() => removeEventListener('keydown', onKey))
</script>
<template><div class="page profile-page">
<header class="page-head sticky-head"><div><h1>{{ t('profilePage') }}</h1><p>{{ t('profileSubtitle') }}</p></div></header>
<div v-if="!native" class="preview-notice panel"><Info /><div><b>{{ t('previewModeTitle') }}</b><small>{{ t('previewModeSync') }}</small></div></div>
<section class="profile-hero">
<i class="hero-orb a" aria-hidden="true" /><i class="hero-orb b" aria-hidden="true" /><i class="hero-orb c" aria-hidden="true" />
<i class="ph-grid" aria-hidden="true" />
<div class="profile-hero-main">
<button class="profile-avatar editable" :title="t('avatarEdit')" @click="avatarOpen = true">
<span class="ph-ring" aria-hidden="true" />
<span class="ph-photo">
<img v-if="store.avatarSrc" :src="store.avatarSrc" alt="" />
<b v-else-if="sync.username">{{ sync.username[0].toUpperCase() }}</b>
<UserRound v-else />
</span>
<i v-if="sync.loggedIn" class="profile-dot" :class="sync.lastError ? 'err' : (sync.online ? 'on' : 'off')" />
<span class="avatar-edit-mask"><Camera /></span>
</button>
<div class="profile-id">
<small class="ph-greet">{{ greeting }}</small>
<b class="ph-name">{{ profile.nickname || (sync.loggedIn ? sync.username : t('notLoggedIn')) }}</b>
<small v-if="profile.title" class="ph-title-tag">{{ profile.title }}</small>
<div v-if="sync.loggedIn" class="profile-badges">
<span class="p-badge" :class="sync.online ? 'on' : 'off'"><component :is="sync.online ? Wifi : WifiOff" />{{ sync.online ? t('online') : t('offline') }}</span>
<span v-if="sync.lastSyncAt" class="p-badge dim"><CalendarClock />{{ localSyncTime.slice(5, 16) }}</span>
<span v-else class="p-badge dim">{{ t('neverSynced') }}</span>
<span v-if="sync.pending > 0" class="p-badge warn"><UploadCloud />{{ t('pendingPush', { n: sync.pending }) }}</span>
</div>
<p v-else class="profile-guest-hint">{{ t('profileGuest') }}</p>
</div>
<div class="profile-hero-actions">
<template v-if="sync.loggedIn">
<button class="btn primary" :disabled="!!busy || sync.syncing" @click="syncNow"><RefreshCw :class="{ spin: busy === 'sync' || sync.syncing }" />{{ busy === 'sync' || sync.syncing ? t('syncingBtn') : t('syncNowBtn') }}</button>
<button class="btn secondary ghost-btn" @click="syncLogout"><LogOut />{{ t('logoutBtn') }}</button>
</template>
<button v-else class="btn primary" :disabled="!native" @click="store.loginOpen = true"><LogIn />{{ t('loginOrRegister') }}</button>
</div>
</div>
<p v-if="sync.lastError" class="db-message">{{ errText(sync.lastError) }}</p>
<p v-if="msg" class="db-message">{{ msg }}</p>
<div class="ph-stats">
<div class="ph-stat"><span class="ph-stat-ico folder"><Folder /></span><div><b>{{ store.dashboard.projects }}</b><span>{{ t('projects') }}</span></div></div>
<div class="ph-stat"><span class="ph-stat-ico done"><CircleCheck /></span><div><b>{{ doneTodos }}</b><span>{{ t('phDoneTodos') }}</span></div></div>
<div class="ph-stat"><span class="ph-stat-ico ticket"><TicketCheck /></span><div><b>{{ resolvedTickets }}</b><span>{{ t('phResolvedTickets') }}</span></div></div>
<div class="ph-stat"><span class="ph-stat-ico star"><Star /></span><div><b>{{ store.favorites.length }}</b><span>{{ t('favoriteProjects') }}</span></div></div>
</div>
</section>
<div class="profile-layout">
<nav class="profile-side" role="tablist">
<button role="tab" :aria-selected="tab === 'info'" :class="{ active: tab === 'info' }" @click="setTab('info')"><IdCard />{{ t('profileTabInfo') }}</button>
<button role="tab" :aria-selected="tab === 'teams'" :class="{ active: tab === 'teams' }" @click="setTab('teams')"><Users />{{ t('profileTabTeams') }}</button>
<button role="tab" :aria-selected="tab === 'assets'" :class="{ active: tab === 'assets' }" @click="setTab('assets')"><Images />{{ t('assetsTab') }}</button>
<button role="tab" :aria-selected="tab === 'security'" :class="{ active: tab === 'security' }" @click="setTab('security')"><ShieldCheck />{{ t('profileSecurity') }}</button>
<button role="tab" :aria-selected="tab === 'sync'" :class="{ active: tab === 'sync' }" @click="setTab('sync')"><CloudUpload />{{ t('profileTabSync') }}</button>
</nav>
<div class="profile-main">
<section v-if="tab === 'info'" class="panel profile-card">
<header class="pc-head">
<span class="pc-badge id"><IdCard /></span>
<div><b>{{ t('profileTabInfo') }}</b><small>{{ t('profileInfoHint') }}</small></div>
</header>
<div class="pc-form">
<label class="pc-field">
<span>{{ t('profileNickname') }}</span>
<div class="pc-input"><UserRound /><input v-model="profile.nickname" :placeholder="sync.username || t('profileNicknamePh')" maxlength="32" /></div>
</label>
<label class="pc-field">
<span>{{ t('profileJobTitle') }}</span>
<div class="pc-input"><IdCard /><input v-model="profile.title" :placeholder="t('profileJobTitlePh')" maxlength="48" /></div>
</label>
<label class="pc-field">
<span>{{ t('profileEmail') }}</span>
<div class="pc-input"><Info /><input v-model="profile.email" type="email" placeholder="you@example.com" maxlength="128" /></div>
</label>
<label class="pc-field pc-field-wide">
<span>{{ t('profileBio') }}</span>
<textarea v-model="profile.bio" class="pc-textarea" :placeholder="t('profileBioPh')" maxlength="300" rows="3" />
</label>
<div class="pc-field pc-field-wide">
<span class="pc-tags-label"><Tags />{{ t('profileTags') }}</span>
<div class="tag-editor">
<span v-for="(tg, i) in profile.techTags" :key="tg" class="tech-tag">{{ tg }}<button type="button" :title="t('delete')" @click="removeTag(i)"><X /></button></span>
<input v-model="tagInput" :placeholder="t('profileTagsPh')" maxlength="24" @keydown.enter.prevent="addTag" @keydown.188.prevent="addTag" @blur="addTag" />
</div>
<small class="pc-hint">{{ t('profileTagsHint') }}</small>
</div>
</div>
<p v-if="profileMsg" class="db-message">{{ profileMsg }}</p>
<div class="pc-actions">
<button class="btn primary" :disabled="!!busy" @click="saveProfile"><Check />{{ busy === 'profile' ? t('saving') : t('profileSaveBtn') }}</button>
</div>
</section>
<section v-else-if="tab === 'teams'" class="panel profile-card">
<header class="pc-head">
<span class="pc-badge team"><Users /></span>
<div><b>{{ t('profileTabTeams') }}</b><small>{{ t('profileTeamsHint') }}</small></div>
</header>
<div v-if="!sync.loggedIn" class="pc-empty">
<span class="pc-empty-ico"><Users /></span>
<p>{{ t('teamLoginHint') }}</p>
<button class="btn primary" :disabled="!native" @click="store.loginOpen = true"><LogIn />{{ t('loginOrRegister') }}</button>
</div>
<template v-else>
<p v-if="teamsErr" class="db-message">{{ errText(teamsErr) }}</p>
<p v-if="teamsLoading" class="pc-hint"><RefreshCw class="spin" /> {{ t('loading') }}</p>
<div v-else-if="teams.length" class="team-pick-list">
<button v-for="tm in teams" :key="tm.id" class="team-pick" :class="{ current: tm.current }" @click="switchTeam(tm)">
<span class="team-pick-badge">{{ tm.name[0] }}</span>
<span class="team-pick-main"><b>{{ tm.name }}</b><small>{{ t('teamRole_' + tm.role) }} · {{ t('teamMembersCount', { n: tm.members }) }}</small></span>
<Check v-if="tm.current" class="team-pick-check" />
</button>
</div>
<p v-else-if="!teamsErr" class="pc-hint">{{ t('teamNoneHint') }}</p>
<div class="team-create-row">
<input v-model="newTeamName" :placeholder="t('teamNamePh')" maxlength="64" @keyup.enter="createTeam" />
<button class="btn secondary" :disabled="!newTeamName.trim() || !!busy" @click="createTeam"><Plus />{{ t('teamCreateBtn') }}</button>
</div>
<div class="pc-actions">
<button class="btn secondary" @click="router.push('/team')">{{ t('teamGoHome') }}<ArrowRight /></button>
</div>
</template>
</section>
<section v-else-if="tab === 'assets'" class="panel profile-card">
<header class="pc-head">
<span class="pc-badge img"><Images /></span>
<div><b>{{ t('assetsTab') }}</b><small>{{ t('assetsHint') }}</small></div>
</header>
<div v-if="!sync.loggedIn" class="pc-empty">
<span class="pc-empty-ico"><Images /></span>
<p>{{ t('syncLoginHint') }}</p>
<button class="btn primary" :disabled="!native" @click="store.loginOpen = true"><LogIn />{{ t('loginOrRegister') }}</button>
</div>
<div v-else-if="!serverStorage" class="pc-empty">
<span class="pc-empty-ico"><Images /></span>
<p>{{ t('assetsNeedServer') }}</p>
</div>
<template v-else>
<div class="assets-bar">
<select v-model="assetsScope" class="fs-select slim" @change="onAssetsScope">
<option value="mine">{{ t('assetsScopeMine') }}</option>
<option v-if="adminTeams.length" value="team">{{ t('assetsScopeTeam') }}</option>
<option v-if="sync.userId === 1" value="all">{{ t('assetsScopeAll') }}</option>
</select>
<select v-if="assetsScope === 'team'" v-model.number="assetsTeamId" class="fs-select slim" @change="reloadAssets">
<option v-for="tm in adminTeams" :key="tm.id" :value="tm.id">{{ tm.name }}</option>
</select>
<span class="assets-count">{{ t('assetsCount', { n: assetsTotal }) }}</span>
<button class="btn secondary" :disabled="assetsLoading" @click="reloadAssets"><RefreshCw :class="{ spin: assetsLoading }" />{{ t('assetsRefresh') }}</button>
</div>
<p v-if="assetsErr" class="db-message">{{ assetsErr }}</p>
<div v-if="assets.length" class="assets-grid">
<figure v-for="f in assets" :key="f.id" class="asset-card">
<a :href="f.url" target="_blank" rel="noreferrer"><img :src="f.url" loading="lazy" alt="" /></a>
<figcaption>
<b :title="f.original || f.name">{{ f.kind === 'avatar' ? t('assetsKindAvatar') : t('assetsKindContent') }} · {{ fmtSize(f.size) }}</b>
<small v-if="assetsScope !== 'mine'">{{ f.username || ('#' + f.userId) }}</small>
<small>{{ (f.createdAt || '').slice(0, 10) }}</small>
</figcaption>
<span class="asset-ops">
<button type="button" :title="t('assetsCopy')" @click="copyAsset(f)"><Copy /></button>
<button type="button" class="danger" :title="t('delete')" @click="deleteAsset(f)"><Trash2 /></button>
</span>
</figure>
</div>
<p v-else-if="!assetsLoading && !assetsErr" class="pc-hint">{{ t('assetsEmpty') }}</p>
<div v-if="assets.length && assets.length < assetsTotal" class="pc-actions assets-more">
<button class="btn secondary" :disabled="assetsLoading" @click="moreAssets">{{ assetsLoading ? t('loading') : t('assetsLoadMore') }}</button>
</div>
</template>
</section>
<section v-else-if="tab === 'security'" class="panel profile-card">
<header class="pc-head">
<span class="pc-badge shield"><ShieldCheck /></span>
<div><b>{{ t('changePasswordBtn') }}</b><small>{{ t('changePasswordHint') }}</small></div>
</header>
<template v-if="sync.loggedIn">
<div class="pc-form">
<label class="pc-field">
<span>{{ t('oldPasswordLabel') }}</span>
<div class="pc-input"><Lock /><input v-model="pwdForm.old" type="password" autocomplete="current-password" /></div>
</label>
<label class="pc-field">
<span>{{ t('newPasswordLabel') }}</span>
<div class="pc-input"><KeyRound /><input v-model="pwdForm.next" type="password" :placeholder="t('passwordPh')" autocomplete="new-password" /></div>
<div v-if="pwdForm.next" class="pc-strength" :data-level="pwdStrength">
<i /><i /><i />
<em>{{ t(strengthLabel) }}</em>
</div>
</label>
<label class="pc-field">
<span>{{ t('confirmPasswordLabel') }}</span>
<div class="pc-input" :class="{ err: pwdForm.confirm && pwdForm.confirm !== pwdForm.next }"><KeyRound /><input v-model="pwdForm.confirm" type="password" autocomplete="new-password" @keyup.enter="changePassword" /></div>
</label>
</div>
<div class="pc-actions">
<button class="btn primary" :disabled="!native || !!busy || !pwdForm.old || !pwdForm.next" @click="changePassword"><KeyRound />{{ busy === 'password' ? t('changingPassword') : t('changePasswordBtn') }}</button>
</div>
</template>
<div v-else class="pc-empty">
<span class="pc-empty-ico"><UserRound /></span>
<p>{{ t('syncLoginHint') }}</p>
<button class="btn primary" :disabled="!native" @click="store.loginOpen = true"><LogIn />{{ t('loginOrRegister') }}</button>
</div>
</section>
<section v-else class="panel profile-card">
<header class="pc-head">
<span class="pc-badge cloud"><CloudUpload /></span>
<div><b>{{ t('profileTabSync') }}</b><small>{{ t('profileSyncHint') }}</small></div>
</header>
<div class="pc-stats">
<div class="pc-stat"><span class="pc-stat-ico user"><UserRound /></span><div><span>{{ t('accountLabel') }}</span><b>{{ sync.loggedIn ? sync.username : t('notLoggedIn') }}</b></div></div>
<div class="pc-stat"><span class="pc-stat-ico" :class="sync.online ? 'net-on' : 'net-off'"><component :is="sync.online ? Wifi : WifiOff" /></span><div><span>{{ t('connectionState') }}</span><b :class="sync.online ? 'ok' : 'warn'">{{ sync.online ? t('online') : t('offline') }}</b></div></div>
<div class="pc-stat"><span class="pc-stat-ico time"><CalendarClock /></span><div><span>{{ t('lastSyncLabel') }}</span><b>{{ localSyncTime || t('neverSynced') }}</b></div></div>
<div class="pc-stat"><span class="pc-stat-ico push" :class="{ warn: sync.pending > 0 }"><UploadCloud /></span><div><span>{{ t('pendingLabel') }}</span><b :class="{ warn: sync.pending > 0 }">{{ sync.pending || 0 }}</b></div></div>
</div>
<div class="pc-scope">
<small>{{ t('syncScope') }}</small>
<div class="pc-scope-tags">
<span v-for="s in scopeTags" :key="s.key" class="pc-tag"><component :is="s.icon" />{{ t(s.key) }}</span>
</div>
</div>
</section>
</div>
</div>
<Teleport to="body">
<div v-if="avatarOpen" class="overlay" @click.self="avatarOpen = false">
<section class="modal avatar-modal" @click.stop>
<header>
<h2><Images class="panel-icon" />{{ t('avatarModalTitle') }}</h2>
<button type="button" @click="avatarOpen = false"><X /></button>
</header>
<div class="avatar-modal-body">
<div class="avatar-row">
<span class="avatar-preview"><img v-if="store.avatarSrc" :src="store.avatarSrc" alt="" /><UserRound v-else /></span>
<div class="avatar-controls">
<div class="sync-actions">
<button class="btn secondary" :disabled="!native" @click="pickAvatar"><ImageUp />{{ t('avatarPick') }}</button>
<button v-if="form.avatarValue" class="btn secondary" @click="clearAvatar"><X />{{ t('avatarClear') }}</button>
</div>
</div>
</div>
<!-- 存储方式由管理员全局配置强制决定用户不再自行选择 -->
<p class="auto-update-hint">{{ t('storageFollowHint', { mode: serverStorage ? t('fsModeServer') : t('fsModeLocal') }) }}</p>
</div>
</section>
</div>
</Teleport>
</div></template>

View File

@@ -0,0 +1,268 @@
<script setup>
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { Users, UserRound, Plus, Check, Crown, Shield, UserPlus, UserMinus, Pencil, Clock, LogOut, Trash2, RefreshCw, X, WifiOff, LogIn, ListTodo, TicketCheck, ClipboardList } from 'lucide-vue-next'
import { call, isNative } from '../api'
import { useAppStore } from '../store'
import { teams, teamsErr, teamsLoading, currentTeam, isTeamAdmin, loadTeams, switchTeam, teamErrCode } from '../team'
import DatePicker from '../components/DatePicker.vue'
const store = useAppStore(), { t } = useI18n(), native = isNative()
const members = ref([])
const busy = ref('')
const newTeamName = ref('')
const renameOpen = ref(false)
const renameVal = ref('')
const digestVal = ref('21:00')
const invite = reactive({ open: false, username: '', role: 'member', err: '' })
const sync = computed(() => store.syncStatus)
const isOwner = computed(() => currentTeam.value?.role === 'owner')
const errText = e => {
const code = teamErrCode(e)
return t('errors.' + code) !== 'errors.' + code ? t('errors.' + code) : String(e)
}
async function loadMembers() {
if (!currentTeam.value) { members.value = []; return }
try { members.value = (await call('TeamMembers', currentTeam.value.id)) || [] }
catch (e) { store.showToast({ type: 'error', text: errText(e) }); members.value = [] }
}
async function refreshAll() {
await loadTeams()
digestVal.value = currentTeam.value?.digestTime || '21:00'
await loadMembers()
}
async function pick(tm) {
if (!tm.current) await switchTeam(tm.id)
}
// 团队切换(本页或 rail 切换器)后刷新成员与摘要时间。
watch(() => currentTeam.value?.id, () => {
digestVal.value = currentTeam.value?.digestTime || '21:00'
loadMembers()
})
async function createTeam() {
const name = newTeamName.value.trim()
if (!name || busy.value) return
busy.value = 'create'
try {
await call('TeamCreate', name)
newTeamName.value = ''
await refreshAll()
store.showToast({ type: 'success', key: 'teamCreatedToast' })
} catch (e) { store.showToast({ type: 'error', text: errText(e) }) }
finally { busy.value = '' }
}
async function doInvite() {
const name = invite.username.trim()
if (!name || busy.value) return
busy.value = 'invite'; invite.err = ''
try {
await call('TeamInvite', currentTeam.value.id, name, invite.role)
invite.open = false; invite.username = ''
await loadMembers()
store.showToast({ type: 'success', key: 'teamInvitedToast' })
} catch (e) { invite.err = errText(e) }
finally { busy.value = '' }
}
async function setRole(m, role) {
try { await call('TeamSetRole', currentTeam.value.id, m.userId, role); await loadMembers() }
catch (e) { store.showToast({ type: 'error', text: errText(e) }) }
}
async function removeMember(m) {
if (!confirm(t('teamRemoveConfirm', { name: m.nickname || m.username }))) return
try { await call('TeamRemoveMember', currentTeam.value.id, m.userId); await loadMembers() }
catch (e) { store.showToast({ type: 'error', text: errText(e) }) }
}
async function doRename() {
const name = renameVal.value.trim()
if (!name) return
try {
await call('TeamRename', currentTeam.value.id, name)
renameOpen.value = false
await loadTeams()
} catch (e) { store.showToast({ type: 'error', text: errText(e) }) }
}
async function saveDigestTime() {
try {
await call('TeamSetDigestTime', currentTeam.value.id, digestVal.value)
store.showToast({ type: 'success', key: 'savedToast' })
} catch (e) { store.showToast({ type: 'error', text: errText(e) }) }
}
async function leaveTeam() {
if (!confirm(t('teamLeaveConfirm'))) return
try { await call('TeamLeave', currentTeam.value.id); await refreshAll() }
catch (e) { store.showToast({ type: 'error', text: errText(e) }) }
}
async function dissolveTeam() {
if (!confirm(t('teamDissolveConfirm', { name: currentTeam.value.name }))) return
try { await call('TeamDissolve', currentTeam.value.id); await refreshAll() }
catch (e) { store.showToast({ type: 'error', text: errText(e) }) }
}
const roleIcon = r => r === 'owner' ? Crown : r === 'admin' ? Shield : UserRound
// ---- 成员卡片快捷创建:给指定成员直接建团队任务/工单(管理员专用) ----
const quick = reactive({ open: false, member: null, kind: 'todo', title: '', priority: 'medium', dueAt: '', err: '' })
function openQuick(m) {
Object.assign(quick, { open: true, member: m, kind: 'todo', title: '', priority: 'medium', dueAt: '', err: '' })
}
async function saveQuick() {
if (!quick.title.trim() || busy.value) return
busy.value = 'quick'; quick.err = ''
try {
await call('TeamTaskSave', {
id: 0, teamId: currentTeam.value.id, kind: quick.kind, title: quick.title, description: '',
priority: quick.priority, assigneeId: quick.member.userId, startAt: '', dueAt: quick.dueAt,
status: '', creatorId: 0, creator: '', assignee: '', urgedAt: '', history: '', updatedAt: ''
})
quick.open = false
store.showToast({ type: 'success', key: 'teamQuickDoneToast', params: { name: quick.member.nickname || quick.member.username } })
store.refreshBadges()
} catch (e) { quick.err = errText(e) }
finally { busy.value = '' }
}
onMounted(refreshAll)
</script>
<template>
<div class="page team-page">
<header class="page-head sticky-head">
<div><h1>{{ t('teamHome') }}</h1><p>{{ t('teamHomeSubtitle') }}</p></div>
<div v-if="currentTeam" class="actions">
<button class="btn secondary" @click="refreshAll"><RefreshCw />{{ t('refresh') }}</button>
</div>
</header>
<!-- 未登录 / 加载失败 / 无团队 -->
<section v-if="!sync.loggedIn" class="panel team-empty">
<span class="team-empty-ico"><Users /></span>
<p>{{ t('teamLoginHint') }}</p>
<button class="btn primary" :disabled="!native" @click="store.loginOpen = true"><LogIn />{{ t('loginOrRegister') }}</button>
</section>
<section v-else-if="teamsErr" class="panel team-empty">
<span class="team-empty-ico warn"><WifiOff /></span>
<p>{{ errText(teamsErr) }}</p>
<button class="btn secondary" @click="refreshAll"><RefreshCw />{{ t('retry') }}</button>
</section>
<section v-else-if="!teamsLoading && !teams.length" class="panel team-empty">
<span class="team-empty-ico"><Users /></span>
<p>{{ t('teamNoneHint') }}</p>
<div class="team-create-row">
<input v-model="newTeamName" :placeholder="t('teamNamePh')" maxlength="64" @keyup.enter="createTeam" />
<button class="btn primary" :disabled="!newTeamName.trim() || !!busy" @click="createTeam"><Plus />{{ t('teamCreateBtn') }}</button>
</div>
</section>
<template v-else-if="currentTeam">
<!-- 团队卡 + 切换器 -->
<section class="panel team-card">
<div class="team-card-head">
<span class="team-logo">{{ currentTeam.name[0] }}</span>
<div class="team-card-main">
<b>{{ currentTeam.name }}
<button v-if="isOwner" class="icon-btn sm" :title="t('teamRenameBtn')" @click="renameVal = currentTeam.name; renameOpen = true"><Pencil /></button>
</b>
<small>{{ t('teamRole_' + currentTeam.role) }} · {{ t('teamMembersCount', { n: members.length || currentTeam.members }) }}</small>
</div>
<div v-if="teams.length > 1" class="team-switch">
<button v-for="tm in teams" :key="tm.id" class="team-chip" :class="{ current: tm.current }" @click="pick(tm)">
{{ tm.name }}<Check v-if="tm.current" />
</button>
</div>
</div>
<div class="team-card-ops">
<label v-if="isTeamAdmin" class="team-digest-time" :title="t('teamDigestTimeHint')">
<Clock /><span>{{ t('teamDigestTime') }}</span>
<input v-model="digestVal" type="time" @change="saveDigestTime" />
</label>
<span class="spacer" />
<button v-if="isTeamAdmin" class="btn secondary" @click="invite.open = true; invite.err = ''"><UserPlus />{{ t('teamInviteBtn') }}</button>
<button v-if="!isOwner" class="btn secondary danger-ghost" @click="leaveTeam"><LogOut />{{ t('teamLeaveBtn') }}</button>
<button v-if="isOwner" class="btn secondary danger-ghost" @click="dissolveTeam"><Trash2 />{{ t('teamDissolveBtn') }}</button>
</div>
</section>
<!-- 成员卡片 -->
<section class="team-members">
<div v-for="m in members" :key="m.userId" class="panel team-member">
<span class="tm-avatar">
<img v-if="m.avatar" :src="m.avatar" alt="" />
<b v-else>{{ (m.nickname || m.username)[0].toUpperCase() }}</b>
</span>
<div class="tm-main">
<b>{{ m.nickname || m.username }}<small v-if="m.nickname" class="tm-account">@{{ m.username }}</small></b>
<small v-if="m.title" class="tm-title">{{ m.title }}</small>
<div v-if="m.techTags?.length" class="tm-tags"><span v-for="tg in m.techTags.slice(0, 6)" :key="tg" class="tech-tag mini">{{ tg }}</span></div>
</div>
<span class="tm-role" :class="m.role"><component :is="roleIcon(m.role)" />{{ t('teamRole_' + m.role) }}</span>
<button v-if="isTeamAdmin" class="tm-quick-btn" :title="t('teamQuickCreate')" @click="openQuick(m)"><ClipboardList /><Plus class="tm-quick-plus" /></button>
<div v-if="m.role !== 'owner' && m.userId !== sync.userId && (isOwner || (isTeamAdmin && m.role === 'member'))" class="tm-ops">
<button v-if="isOwner && m.role === 'member'" :title="t('teamMakeAdmin')" @click="setRole(m, 'admin')"><Shield /></button>
<button v-if="isOwner && m.role === 'admin'" :title="t('teamMakeMember')" @click="setRole(m, 'member')"><UserRound /></button>
<button class="danger" :title="t('teamRemoveBtn')" @click="removeMember(m)"><UserMinus /></button>
</div>
</div>
</section>
<!-- 再建一个团队 -->
<section class="panel team-create-panel">
<small>{{ t('teamCreateMore') }}</small>
<div class="team-create-row">
<input v-model="newTeamName" :placeholder="t('teamNamePh')" maxlength="64" @keyup.enter="createTeam" />
<button class="btn secondary" :disabled="!newTeamName.trim() || !!busy" @click="createTeam"><Plus />{{ t('teamCreateBtn') }}</button>
</div>
</section>
</template>
<!-- 邀请成员 -->
<Teleport to="body">
<div v-if="invite.open" class="overlay" @click.self="invite.open = false">
<section class="modal team-modal" @click.stop>
<header><h2><UserPlus class="panel-icon" />{{ t('teamInviteBtn') }}</h2><button type="button" @click="invite.open = false"><X /></button></header>
<div class="team-modal-body">
<label>{{ t('teamInviteUser') }}<input v-model="invite.username" :placeholder="t('teamInviteUserPh')" @keyup.enter="doInvite" /></label>
<label>{{ t('teamInviteRole') }}<select v-model="invite.role">
<option value="member">{{ t('teamRole_member') }}</option>
<option value="admin">{{ t('teamRole_admin') }}</option>
</select></label>
<p v-if="invite.err" class="db-message">{{ invite.err }}</p>
<div class="modal-actions"><button class="btn primary" :disabled="!invite.username.trim() || !!busy" @click="doInvite"><UserPlus />{{ t('teamInviteBtn') }}</button></div>
</div>
</section>
</div>
<div v-if="renameOpen" class="overlay" @click.self="renameOpen = false">
<section class="modal team-modal" @click.stop>
<header><h2><Pencil class="panel-icon" />{{ t('teamRenameBtn') }}</h2><button type="button" @click="renameOpen = false"><X /></button></header>
<div class="team-modal-body">
<label>{{ t('teamName') }}<input v-model="renameVal" maxlength="64" @keyup.enter="doRename" /></label>
<div class="modal-actions"><button class="btn primary" :disabled="!renameVal.trim()" @click="doRename"><Check />{{ t('save') }}</button></div>
</div>
</section>
</div>
<!-- 成员快捷创建任务 / 工单 -->
<div v-if="quick.open" class="overlay" @click.self="quick.open = false">
<section class="modal team-modal" @click.stop>
<header>
<h2><ClipboardList class="panel-icon" />{{ t('teamQuickFor', { name: quick.member.nickname || quick.member.username }) }}</h2>
<button type="button" @click="quick.open = false"><X /></button>
</header>
<div class="team-modal-body">
<div class="quick-kind" role="radiogroup">
<button type="button" role="radio" :aria-checked="quick.kind === 'todo'" :class="{ active: quick.kind === 'todo' }" @click="quick.kind = 'todo'"><ListTodo />{{ t('teamKindTodo') }}</button>
<button type="button" role="radio" :aria-checked="quick.kind === 'ticket'" :class="{ active: quick.kind === 'ticket' }" @click="quick.kind = 'ticket'"><TicketCheck />{{ t('teamKindTicket') }}</button>
</div>
<label>{{ t('todoTitle') }}<input v-model="quick.title" :placeholder="t('teamTaskTitlePh')" maxlength="200" @keyup.enter="saveQuick" /></label>
<label>{{ t('priorityLabel') }}<select v-model="quick.priority">
<option value="low">{{ t('priority.low') }}</option>
<option value="medium">{{ t('priority.medium') }}</option>
<option value="high">{{ t('priority.high') }}</option>
</select></label>
<label>{{ t('dueDate') }}<DatePicker v-model="quick.dueAt" /></label>
<p v-if="quick.err" class="db-message">{{ quick.err }}</p>
<div class="modal-actions"><button class="btn primary" :disabled="!quick.title.trim() || !!busy" @click="saveQuick"><Check />{{ t('teamQuickCreateBtn') }}</button></div>
</div>
</section>
</div>
</Teleport>
</div>
</template>

View File

@@ -0,0 +1,173 @@
<script setup>
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { NotebookPen, Send, BellRing, Sparkles, RefreshCw, Users, LogIn, WifiOff, Quote, Check, ChevronLeft, ChevronRight, UserRound } from 'lucide-vue-next'
import { call, isNative, on } from '../api'
import { useAppStore } from '../store'
import { teamsErr, currentTeam, isTeamAdmin, loadTeams, teamErrCode } from '../team'
import MarkdownView from '../components/MarkdownView.vue'
import DatePicker from '../components/DatePicker.vue'
const store = useAppStore(), { t } = useI18n(), native = isNative()
const sync = computed(() => store.syncStatus)
const dayStr = d => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
const date = ref(dayStr(new Date()))
const board = ref(null)
const myDraft = ref('')
const busy = ref('')
const digestBusy = ref(false)
let offDigest = null
const errText = e => {
const code = teamErrCode(e)
return t('errors.' + code) !== 'errors.' + code ? t('errors.' + code) : String(e)
}
const isToday = computed(() => date.value === dayStr(new Date()))
const myReport = computed(() => board.value?.reports?.find(r => r.userId === sync.value.userId))
const otherReports = computed(() => (board.value?.reports || []).filter(r => r.userId !== sync.value.userId))
async function load() {
if (!currentTeam.value) return
try {
board.value = await call('TeamReportBoardGet', currentTeam.value.id, date.value)
if (myReport.value && !myDraft.value) myDraft.value = myReport.value.content
} catch (e) { store.showToast({ type: 'error', text: errText(e) }) }
}
async function boot() {
await loadTeams()
if (currentTeam.value) await load()
}
// rail 切换器换团队后重置看板并重新加载。
watch(() => currentTeam.value?.id, id => {
board.value = null
myDraft.value = ''
if (id) load()
})
function shiftDay(n) {
const d = new Date(date.value + 'T12:00')
d.setDate(d.getDate() + n)
date.value = dayStr(d)
myDraft.value = ''
board.value = null
load()
}
async function quoteDayReport() {
try {
const briefs = await call('GetAISummaries', 0)
const r = (briefs || []).find(x => x.kind === 'dayreport')
if (r?.content) myDraft.value = myDraft.value ? myDraft.value + '\n\n' + r.content : r.content
else store.showToast({ type: 'error', key: 'teamNoDayReport' })
} catch (e) { store.showToast({ type: 'error', text: errText(e) }) }
}
async function submit() {
if (!myDraft.value.trim() || busy.value) return
busy.value = 'submit'
try {
await call('TeamReportSubmit', currentTeam.value.id, date.value, myDraft.value)
await load()
store.showToast({ type: 'success', key: 'teamReportSubmittedToast' })
} catch (e) { store.showToast({ type: 'error', text: errText(e) }) }
finally { busy.value = '' }
}
async function urgeReport(m) {
try {
await call('TeamReportUrge', currentTeam.value.id, m.userId, date.value)
store.showToast({ type: 'success', key: 'teamUrgedToast' })
} catch (e) { store.showToast({ type: 'error', text: errText(e) }) }
}
async function generateDigest() {
if (digestBusy.value) return
digestBusy.value = true
try {
await call('TeamDigestGenerate', currentTeam.value.id, date.value)
store.showToast({ type: 'success', key: 'teamDigestStartedToast' })
} catch (e) {
digestBusy.value = false
store.showToast({ type: 'error', text: errText(e) })
}
}
const fmtTime = v => (v || '').replace('T', ' ').slice(5, 16)
onMounted(() => {
boot()
offDigest = on('team:digest', p => {
if (p?.teamId !== currentTeam.value?.id || p?.date !== date.value) return
digestBusy.value = false
if (p.error) store.showToast({ type: 'error', text: errText(p.error) })
else { store.showToast({ type: 'success', key: 'teamDigestDoneToast' }); load() }
})
})
onUnmounted(() => offDigest?.())
</script>
<template>
<div class="page team-page">
<header class="page-head sticky-head">
<div><h1>{{ t('teamReports') }}</h1><p>{{ t('teamReportsSubtitle') }}</p></div>
<div v-if="currentTeam" class="actions team-date-nav">
<button class="icon-btn" :title="t('prevDay')" @click="shiftDay(-1)"><ChevronLeft /></button>
<DatePicker v-model="date" class="report-date" :clearable="false" @update:model-value="myDraft = ''; board = null; load()" />
<button class="icon-btn" :disabled="isToday" :title="t('nextDay')" @click="shiftDay(1)"><ChevronRight /></button>
</div>
</header>
<section v-if="!sync.loggedIn" class="panel team-empty">
<span class="team-empty-ico"><Users /></span>
<p>{{ t('teamLoginHint') }}</p>
<button class="btn primary" :disabled="!native" @click="store.loginOpen = true"><LogIn />{{ t('loginOrRegister') }}</button>
</section>
<section v-else-if="teamsErr" class="panel team-empty">
<span class="team-empty-ico warn"><WifiOff /></span><p>{{ errText(teamsErr) }}</p>
<button class="btn secondary" @click="boot"><RefreshCw />{{ t('retry') }}</button>
</section>
<section v-else-if="!currentTeam" class="panel team-empty">
<span class="team-empty-ico"><Users /></span>
<p>{{ t('teamNoneHint') }}</p>
<router-link class="btn primary" to="/team">{{ t('teamGoHome') }}</router-link>
</section>
<template v-else>
<!-- 我的日报 -->
<section class="panel team-report-mine">
<header class="pc-head">
<span class="pc-badge id"><NotebookPen /></span>
<div><b>{{ t('teamMyReport') }}</b><small>{{ myReport ? t('teamReportSubmittedAt', { at: fmtTime(myReport.submittedAt) }) : t('teamReportNotSubmitted') }}</small></div>
</header>
<textarea v-model="myDraft" class="team-report-editor" rows="6" :placeholder="t('teamReportPh')" />
<div class="team-report-ops">
<button v-if="isToday" class="btn secondary" :title="t('teamQuoteDayReportHint')" @click="quoteDayReport"><Quote />{{ t('teamQuoteDayReport') }}</button>
<span class="spacer" />
<button class="btn primary" :disabled="!myDraft.trim() || !!busy" @click="submit"><Send />{{ myReport ? t('teamReportUpdateBtn') : t('teamReportSubmitBtn') }}</button>
</div>
</section>
<!-- AI 摘要 -->
<section class="panel team-digest">
<header class="pc-head">
<span class="pc-badge ai"><Sparkles /></span>
<div><b>{{ t('teamDigest') }}</b><small>{{ board?.digest ? t('teamDigestAt', { at: fmtTime(board.digest.generatedAt), provider: board.digest.provider }) : t('teamDigestNone') }}</small></div>
<span class="spacer" />
<button v-if="isTeamAdmin" class="btn secondary" :disabled="digestBusy" @click="generateDigest">
<component :is="digestBusy ? RefreshCw : Sparkles" :class="{ spin: digestBusy }" />{{ digestBusy ? t('generating') : (board?.digest ? t('teamDigestRegen') : t('teamDigestGen')) }}
</button>
</header>
<MarkdownView v-if="board?.digest" class="md-body" :source="board.digest.content" />
</section>
<!-- 成员提交状态 -->
<section class="team-report-grid">
<div v-for="r in otherReports" :key="r.userId" class="panel team-report-card ok">
<header><span class="trc-ava"><Check /></span><b>{{ r.user }}</b><time>{{ fmtTime(r.submittedAt) }}</time></header>
<MarkdownView v-if="r.content" class="md-body sm" :source="r.content" />
<p v-else class="team-none">{{ t('teamReportContentHidden') }}</p>
</div>
<div v-for="m in board?.missing || []" :key="'m' + m.userId" class="panel team-report-card miss">
<header>
<span class="trc-ava miss"><UserRound /></span><b>{{ m.nickname || m.username }}</b>
<em>{{ t('teamReportMissing') }}</em>
<button v-if="isTeamAdmin && m.userId !== sync.userId" class="btn secondary sm" @click="urgeReport(m)"><BellRing />{{ t('teamUrgeBtn') }}</button>
</header>
</div>
</section>
</template>
</div>
</template>

View File

@@ -0,0 +1,234 @@
<script setup>
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { ClipboardList, ListTodo, TicketCheck, Plus, Play, Check, X, BellRing, Trash2, Pencil, Flag, RefreshCw, Users, Share2, LogIn, WifiOff, UserRound } from 'lucide-vue-next'
import { call, isNative } from '../api'
import { useAppStore } from '../store'
import { teams, teamsErr, currentTeam, isTeamAdmin, loadTeams, teamErrCode } from '../team'
import MarkdownView from '../components/MarkdownView.vue'
const store = useAppStore(), { t } = useI18n(), native = isNative()
const tasks = ref([])
const shared = ref([])
const filter = ref('all')
const busy = ref('')
const detail = ref(null)
const members = ref([])
const edit = reactive({ open: false, id: 0, kind: 'todo', title: '', description: '', priority: 'medium', assigneeId: 0, startAt: '', dueAt: '', err: '' })
const sync = computed(() => store.syncStatus)
const errText = e => {
const code = teamErrCode(e)
return t('errors.' + code) !== 'errors.' + code ? t('errors.' + code) : String(e)
}
const filters = ['all', 'mine', 'created', 'open']
async function load() {
if (!currentTeam.value) return
try {
;[tasks.value, shared.value] = await Promise.all([
call('TeamTaskList', currentTeam.value.id, filter.value),
call('TeamSharedItems', currentTeam.value.id)
])
} catch (e) { store.showToast({ type: 'error', text: errText(e) }) }
}
async function boot() {
await loadTeams()
if (!currentTeam.value) return
load()
try { members.value = (await call('TeamMembers', currentTeam.value.id)) || [] } catch {}
}
// rail 切换器换团队后刷新任务与成员。
watch(() => currentTeam.value?.id, async id => {
tasks.value = []; shared.value = []; members.value = []
if (!id) return
load()
try { members.value = (await call('TeamMembers', id)) || [] } catch {}
})
function setFilter(f) { filter.value = f; load() }
function openCreate() {
Object.assign(edit, { open: true, id: 0, kind: 'todo', title: '', description: '', priority: 'medium', assigneeId: 0, startAt: '', dueAt: '', err: '' })
}
function openEdit(x) {
Object.assign(edit, { open: true, id: x.id, kind: x.kind, title: x.title, description: x.description, priority: x.priority, assigneeId: x.assigneeId, startAt: x.startAt, dueAt: x.dueAt, err: '' })
}
async function saveTask() {
if (!edit.title.trim() || busy.value) return
busy.value = 'save'; edit.err = ''
try {
await call('TeamTaskSave', {
id: edit.id, teamId: currentTeam.value.id, kind: edit.kind, title: edit.title, description: edit.description,
priority: edit.priority, assigneeId: Number(edit.assigneeId) || 0, startAt: edit.startAt, dueAt: edit.dueAt,
status: '', creatorId: 0, creator: '', assignee: '', urgedAt: '', history: '', updatedAt: ''
})
edit.open = false
await load()
store.showToast({ type: 'success', key: 'savedToast' })
store.refreshBadges()
} catch (e) { edit.err = errText(e) }
finally { busy.value = '' }
}
async function setStatus(x, status) {
try { await call('TeamTaskSetStatus', currentTeam.value.id, x.id, status); await load(); store.refreshBadges() }
catch (e) { store.showToast({ type: 'error', text: errText(e) }) }
}
async function urge(x) {
try { await call('TeamTaskUrge', currentTeam.value.id, x.id); store.showToast({ type: 'success', key: 'teamUrgedToast' }); await load() }
catch (e) { store.showToast({ type: 'error', text: errText(e) }) }
}
async function del(x) {
if (!confirm(t('teamTaskDeleteConfirm', { title: x.title }))) return
try { await call('TeamTaskDelete', currentTeam.value.id, x.id); detail.value = null; await load(); store.refreshBadges() }
catch (e) { store.showToast({ type: 'error', text: errText(e) }) }
}
async function urgeShared(it) {
try { await call('TeamUrgeShared', currentTeam.value.id, it.kind, it.uuid); store.showToast({ type: 'success', key: 'teamUrgedToast' }) }
catch (e) { store.showToast({ type: 'error', text: errText(e) }) }
}
const canFlow = x => isTeamAdmin.value || x.assigneeId === sync.value.userId
const flowActions = x => {
if (!canFlow(x)) return []
if (x.status === 'open') return [{ s: 'doing', label: t('taskStart'), icon: Play }, { s: 'done', label: t('taskDone'), icon: Check }]
if (x.status === 'doing') return [{ s: 'done', label: t('taskDone'), icon: Check }]
return [{ s: 'open', label: t('teamReopen'), icon: RefreshCw }]
}
const fmtTime = v => (v || '').replace('T', ' ').slice(5, 16)
const statusCls = s => ({ open: 'st-open', doing: 'st-doing', in_progress: 'st-doing', done: 'st-done', resolved: 'st-done', closed: 'st-closed' }[s] || 'st-open')
// 共享条目状态来自个人待办/工单两套枚举,按序查表
const sharedStatusLabel = s => {
for (const k of ['todoStatus.' + s, 'ticketStatus.' + s]) if (t(k) !== k) return t(k)
return s
}
onMounted(boot)
</script>
<template>
<div class="page team-page">
<header class="page-head sticky-head">
<div><h1>{{ t('teamTasks') }}</h1><p>{{ t('teamTasksSubtitle') }}</p></div>
<div v-if="currentTeam && isTeamAdmin" class="actions">
<button class="btn primary" @click="openCreate"><Plus />{{ t('teamTaskNew') }}</button>
</div>
</header>
<section v-if="!sync.loggedIn" class="panel team-empty">
<span class="team-empty-ico"><Users /></span>
<p>{{ t('teamLoginHint') }}</p>
<button class="btn primary" :disabled="!native" @click="store.loginOpen = true"><LogIn />{{ t('loginOrRegister') }}</button>
</section>
<section v-else-if="teamsErr" class="panel team-empty">
<span class="team-empty-ico warn"><WifiOff /></span><p>{{ errText(teamsErr) }}</p>
<button class="btn secondary" @click="boot"><RefreshCw />{{ t('retry') }}</button>
</section>
<section v-else-if="!currentTeam" class="panel team-empty">
<span class="team-empty-ico"><Users /></span>
<p>{{ t('teamNoneHint') }}</p>
<router-link class="btn primary" to="/team">{{ t('teamGoHome') }}</router-link>
</section>
<template v-else>
<div class="team-filter" role="tablist">
<button v-for="f in filters" :key="f" role="tab" :class="{ active: filter === f }" @click="setFilter(f)">{{ t('teamFilter_' + f) }}</button>
</div>
<section class="panel team-task-panel">
<p v-if="!tasks.length" class="team-none">{{ t('teamTasksEmpty') }}</p>
<div v-for="x in tasks" :key="x.id" class="team-task" :class="{ done: x.status === 'done' || x.status === 'closed' }" @click="detail = x">
<span class="tc-icon" :class="x.kind"><component :is="x.kind === 'todo' ? ListTodo : TicketCheck" /></span>
<div class="tt-main">
<b>{{ x.title }}<span v-if="x.urgedAt" class="tt-urged" :title="t('teamUrgedAt') + ' ' + fmtTime(x.urgedAt)"><BellRing /></span></b>
<small>
<span class="tt-status" :class="statusCls(x.status)">{{ t('teamStatus_' + x.status) }}</span>
<span v-if="x.priority === 'high'" class="tc-pri"><Flag />{{ t('priority.high') }}</span>
<span v-if="x.assignee" class="tt-assignee"><UserRound />{{ x.assignee }}</span>
<time v-if="x.dueAt">{{ fmtTime(x.dueAt) }}</time>
</small>
</div>
<div class="tc-acts" @click.stop>
<button v-for="a in flowActions(x)" :key="a.s" :title="a.label" @click="setStatus(x, a.s)"><component :is="a.icon" />{{ a.label }}</button>
<button v-if="isTeamAdmin && x.assigneeId && ['open', 'doing'].includes(x.status)" class="warn" :title="t('teamUrgeBtn')" @click="urge(x)"><BellRing /></button>
<button v-if="isTeamAdmin" :title="t('edit')" @click="openEdit(x)"><Pencil /></button>
<button v-if="isTeamAdmin" class="danger" :title="t('delete')" @click="del(x)"><Trash2 /></button>
</div>
</div>
</section>
<!-- 成员共享的个人条目 -->
<section class="team-shared">
<h2 class="today-title"><Share2 />{{ t('teamSharedItems') }}<em>{{ shared.length }}</em></h2>
<div class="panel team-task-panel">
<p v-if="!shared.length" class="team-none">{{ t('teamSharedEmpty') }}</p>
<div v-for="it in shared" :key="it.kind + it.uuid" class="team-task shared">
<span class="tc-icon" :class="it.kind"><component :is="it.kind === 'todo' ? ListTodo : TicketCheck" /></span>
<div class="tt-main">
<b>{{ it.title }}</b>
<small>
<span class="tt-owner"><UserRound />{{ it.owner }}</span>
<span class="tt-status" :class="statusCls(it.status)">{{ sharedStatusLabel(it.status) }}</span>
<span v-if="it.priority === 'high'" class="tc-pri"><Flag />{{ t('priority.high') }}</span>
<time v-if="it.dueAt">{{ fmtTime(it.dueAt) }}</time>
</small>
</div>
<div class="tc-acts">
<button v-if="isTeamAdmin && it.userId !== sync.userId" class="warn" :title="t('teamUrgeBtn')" @click="urgeShared(it)"><BellRing />{{ t('teamUrgeBtn') }}</button>
</div>
</div>
</div>
</section>
</template>
<Teleport to="body">
<!-- 新建 / 编辑 -->
<div v-if="edit.open" class="overlay" @click.self="edit.open = false">
<section class="modal team-modal wide" @click.stop>
<header><h2><ClipboardList class="panel-icon" />{{ edit.id ? t('teamTaskEdit') : t('teamTaskNew') }}</h2><button type="button" @click="edit.open = false"><X /></button></header>
<div class="team-modal-body grid2">
<label>{{ t('todoTitle') }}<input v-model="edit.title" :placeholder="t('teamTaskTitlePh')" maxlength="200" /></label>
<label>{{ t('ticketTypeLabel') }}<select v-model="edit.kind">
<option value="todo">{{ t('teamKindTodo') }}</option>
<option value="ticket">{{ t('teamKindTicket') }}</option>
</select></label>
<label>{{ t('priorityLabel') }}<select v-model="edit.priority">
<option value="low">{{ t('priority.low') }}</option>
<option value="medium">{{ t('priority.medium') }}</option>
<option value="high">{{ t('priority.high') }}</option>
</select></label>
<label>{{ t('teamAssignee') }}<select v-model="edit.assigneeId">
<option :value="0">{{ t('teamUnassigned') }}</option>
<option v-for="m in members" :key="m.userId" :value="m.userId">{{ m.nickname || m.username }}</option>
</select></label>
<label>{{ t('startDate') }}<input v-model="edit.startAt" type="datetime-local" /></label>
<label>{{ t('dueDate') }}<input v-model="edit.dueAt" type="datetime-local" /></label>
<label class="span2">{{ t('teamTaskDesc') }}<textarea v-model="edit.description" rows="6" :placeholder="t('mdPlaceholder')" /></label>
</div>
<p v-if="edit.err" class="db-message">{{ edit.err }}</p>
<div class="modal-actions pad">
<button class="btn primary" :disabled="!edit.title.trim() || !!busy" @click="saveTask"><Check />{{ t('save') }}</button>
</div>
</section>
</div>
<!-- 详情 -->
<div v-if="detail" class="overlay" @click.self="detail = null">
<section class="modal team-modal wide" @click.stop>
<header>
<h2><component :is="detail.kind === 'todo' ? ListTodo : TicketCheck" class="panel-icon" />{{ detail.title }}</h2>
<button type="button" @click="detail = null"><X /></button>
</header>
<div class="team-detail">
<div class="team-detail-meta">
<span class="tt-status" :class="statusCls(detail.status)">{{ t('teamStatus_' + detail.status) }}</span>
<span v-if="detail.creator"><b>{{ t('teamCreator') }}:</b> {{ detail.creator }}</span>
<span><b>{{ t('teamAssignee') }}:</b> {{ detail.assignee || t('teamUnassigned') }}</span>
<span v-if="detail.dueAt"><b>{{ t('dueDate') }}:</b> {{ fmtTime(detail.dueAt) }}</span>
<span v-if="detail.urgedAt" class="warn"><BellRing />{{ t('teamUrgedAt') }} {{ fmtTime(detail.urgedAt) }}</span>
</div>
<MarkdownView v-if="detail.description" class="md-body" :source="detail.description" />
<p v-else class="team-none">{{ t('noDescription') }}</p>
</div>
</section>
</div>
</Teleport>
</div>
</template>

View File

@@ -0,0 +1,193 @@
<script setup>
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { Plus, TicketCheck, Pencil, Trash2, RefreshCw, CalendarDays, Flag, Play, Check, Archive, RotateCcw, X, Bug, Sparkles, Wrench, ListChecks, ImagePlus } from 'lucide-vue-next'
import { call, notifyTasksChanged, onTasksChanged } from '../api'
import { useAppStore } from '../store'
import MarkdownView from '../components/MarkdownView.vue'
import DueQuickPick from '../components/DueQuickPick.vue'
import DatePicker from '../components/DatePicker.vue'
import LifecycleTimeline from '../components/LifecycleTimeline.vue'
import { useMdEditor } from '../mdeditor'
const route = useRoute()
const router = useRouter()
const { t } = useI18n()
const store = useAppStore()
const tickets = ref([])
const statusFilter = ref('all')
const projectFilter = ref(0)
const modal = ref(false)
const saving = ref(false)
const error = ref('')
const form = reactive({ id: 0, title: '', description: '', type: 'task', projectId: 0, startAt: '', dueAt: '', priority: 'medium', status: 'open' })
const current = ref(null) // 正在编辑的原始条目(时间线展示已保存的轨迹)
const md = useMdEditor(form, 'description', e => { error.value = errText(e) })
const statuses = ['open', 'in_progress', 'resolved', 'closed']
const typeIcon = { feature: Sparkles, bug: Bug, task: ListChecks, improvement: Wrench }
const filtered = computed(() => tickets.value.filter(x =>
(statusFilter.value === 'all' || x.status === statusFilter.value) &&
(!projectFilter.value || x.projectId === projectFilter.value)
))
const counts = computed(() => Object.fromEntries(['all', ...statuses].map(s => [s, s === 'all' ? tickets.value.length : tickets.value.filter(x => x.status === s).length])))
const overdue = x => x.dueAt && ['open', 'in_progress'].includes(x.status) && new Date(x.dueAt.length === 10 ? x.dueAt + 'T23:59' : x.dueAt) < new Date()
// 状态流转:开始处理 → 解决 → 关闭;已解决/已关闭可重新打开。
const flowActions = x => ({
open: [{ key: 'in_progress', label: t('ticketFlow.start'), icon: Play }],
in_progress: [{ key: 'resolved', label: t('ticketFlow.resolve'), icon: Check }],
resolved: [{ key: 'closed', label: t('ticketFlow.close'), icon: Archive }, { key: 'open', label: t('ticketFlow.reopen'), icon: RotateCcw }],
closed: [{ key: 'open', label: t('ticketFlow.reopen'), icon: RotateCcw }]
}[x.status] || [])
async function load() {
tickets.value = await call('ListTickets', 'all', 0)
}
// 本页写入后广播(顶栏任务中心等同步刷新);也接收其它入口的变更
async function reloadAndNotify() {
await load()
notifyTasksChanged()
}
function openModal(x) {
Object.assign(form, x
? { id: x.id, title: x.title, description: x.description, type: x.type, projectId: x.projectId, startAt: x.startAt, dueAt: x.dueAt, priority: x.priority, status: x.status }
: { id: 0, title: '', description: '', type: 'task', projectId: projectFilter.value || store.projects[0]?.id || 0, startAt: new Date().toISOString().slice(0, 10), dueAt: '', priority: 'medium', status: 'open' })
current.value = x || null
error.value = ''
md.reset()
modal.value = true
}
const errText = raw => {
const code = ['TICKET_TITLE_REQUIRED', 'TICKET_PROJECT_REQUIRED', 'TICKET_SCHEDULE_REQUIRED', 'TICKET_SCHEDULE_INVALID', 'TICKET_STATUS_INVALID'].find(c => String(raw).includes(c))
return code ? t(`errors.${code}`) : String(raw)
}
async function save() {
if (saving.value) return
saving.value = true
error.value = ''
try {
await call('SaveTicket', { ...form, projectId: Number(form.projectId) || 0 })
modal.value = false
await reloadAndNotify()
} catch (e) {
error.value = errText(e)
} finally {
saving.value = false
}
}
async function setStatus(x, status) {
await call('SetTicketStatus', x.id, status)
await reloadAndNotify()
}
async function remove(x) {
if (!confirm(`${t('delete')} ${x.title}?`)) return
await call('DeleteTicket', x.id)
await reloadAndNotify()
}
// 深链 ?edit=id任务中心/日历详情点“编辑”后直接弹出编辑框
function openEditFromQuery() {
const id = Number(route.query.edit)
if (!id) return
router.replace('/tickets')
const x = tickets.value.find(i => i.id === id)
if (x) openModal(x)
}
onMounted(async () => {
await load()
openEditFromQuery()
})
watch(() => route.query.edit, v => { if (v) openEditFromQuery() })
const offTasks = onTasksChanged(load)
onUnmounted(() => offTasks())
</script>
<template>
<div class="page tickets-page">
<header class="page-head sticky-head">
<div><h1>{{ t('tickets') }}</h1><p>{{ t('ticketsSubtitle') }}</p></div>
<div class="actions">
<button class="btn secondary" @click="load"><RefreshCw />{{ t('refresh') }}</button>
<button class="btn primary" @click="openModal()"><Plus />{{ t('addTicket') }}</button>
</div>
</header>
<div class="todo-toolbar">
<div class="tabs compact ticket-tabs">
<button v-for="s in ['all', ...statuses]" :key="s" :class="{ active: statusFilter === s }" @click="statusFilter = s">
{{ s === 'all' ? t('allLogs') : t('ticketStatus.' + s) }}<i class="tab-count">{{ counts[s] }}</i>
</button>
</div>
<select v-model.number="projectFilter" class="log-category">
<option :value="0">{{ t('allProjects') }}</option>
<option v-for="p in store.projects" :key="p.id" :value="p.id">{{ p.name }}</option>
</select>
</div>
<section class="panel ticket-panel">
<article v-for="x in filtered" :key="x.id" class="ticket-row" :class="[x.status, x.priority, { overdue: overdue(x) }]">
<span class="ticket-type" :class="x.type" :title="t('ticketType.' + x.type)"><component :is="typeIcon[x.type] || ListChecks" /></span>
<div class="ticket-main" @click="openModal(x)">
<div class="ticket-title"><b>{{ x.title }}</b><span class="ticket-status" :class="x.status">{{ t('ticketStatus.' + x.status) }}</span></div>
<MarkdownView v-if="x.description && x.description.trim() !== x.title.trim()" class="md-clamp ticket-desc" :source="x.description" />
<div class="todo-meta">
<span class="todo-chip">{{ x.projectName || ('#' + x.projectId) }}</span>
<span class="todo-due" :class="{ overdue: overdue(x) }"><CalendarDays />{{ x.startAt.replace('T', ' ') }} {{ x.dueAt.replace('T', ' ') }}</span>
<span class="todo-priority" :class="x.priority"><Flag />{{ t('priority.' + x.priority) }}</span>
</div>
</div>
<div class="ticket-actions">
<button v-for="act in flowActions(x)" :key="act.key" class="btn secondary flow-btn" @click="setStatus(x, act.key)"><component :is="act.icon" />{{ act.label }}</button>
<div class="icon-actions">
<button :title="t('edit')" @click="openModal(x)"><Pencil /></button>
<button :title="t('delete')" @click="remove(x)"><Trash2 /></button>
</div>
</div>
</article>
<div v-if="!filtered.length" class="empty"><TicketCheck />{{ t('noTickets') }}</div>
</section>
</div>
<Teleport to="body">
<div v-if="modal" class="overlay" @click.self="!saving && (modal = false)">
<form class="modal modal-split" @submit.prevent="save">
<header><h2>{{ form.id ? t('editTicket') : t('addTicket') }}</h2><button type="button" :disabled="saving" @click="modal = false"><X /></button></header>
<div class="split-body">
<div class="split-fields">
<label>{{ t('ticketTitle') }}<input v-model="form.title" :disabled="saving" required /></label>
<label>{{ t('relatedProject') }} *<select v-model.number="form.projectId" :disabled="saving" required><option :value="0" disabled>{{ t('selectProject') }}</option><option v-for="p in store.projects" :key="p.id" :value="p.id">{{ p.name }}</option></select></label>
<label>{{ t('ticketTypeLabel') }}<select v-model="form.type" :disabled="saving"><option v-for="k in ['feature', 'bug', 'task', 'improvement']" :key="k" :value="k">{{ t('ticketType.' + k) }}</option></select></label>
<label>{{ t('startDate') }} *<DatePicker v-model="form.startAt" :disabled="saving" :clearable="false" /></label>
<label>{{ t('dueDate') }} *<DatePicker v-model="form.dueAt" :disabled="saving" :clearable="false" />
<DueQuickPick v-model="form.dueAt" :disabled="saving" />
</label>
<div class="field-pair">
<label>{{ t('priorityLabel') }}<select v-model="form.priority" :disabled="saving"><option value="low">{{ t('priority.low') }}</option><option value="medium">{{ t('priority.medium') }}</option><option value="high">{{ t('priority.high') }}</option></select></label>
<label>{{ t('statusLabel') }}<select v-model="form.status" :disabled="saving"><option v-for="s in statuses" :key="s" :value="s">{{ t('ticketStatus.' + s) }}</option></select></label>
</div>
<LifecycleTimeline v-if="current" :history="current.history" :created-at="current.createdAt" :updated-at="current.updatedAt" :status="current.status" kind="ticket" />
</div>
<div class="split-editor">
<div class="md-toolbar">
<span class="md-field-label">{{ t('ticketDesc') }}</span>
<div class="tabs compact">
<button type="button" :class="{ active: !md.preview.value }" @click="md.preview.value = false">{{ t('mdEdit') }}</button>
<button type="button" :class="{ active: md.preview.value }" @click="md.preview.value = true">{{ t('mdPreviewTab') }}</button>
</div>
<button type="button" class="btn secondary md-img-btn" :disabled="saving || md.uploading.value" @click="md.pickImage"><ImagePlus />{{ md.uploading.value ? t('mdInserting') : t('insertImage') }}</button>
</div>
<div class="md-editor">
<textarea v-show="!md.preview.value" :ref="md.inputEl" v-model="form.description" :disabled="saving" :placeholder="t('mdPlaceholder')" @paste="md.onPaste" />
<MarkdownView v-if="md.preview.value" class="md-preview-box" :source="form.description || t('mdEmpty')" />
</div>
</div>
</div>
<footer>
<p v-if="error" class="form-error">{{ error }}</p>
<button type="button" class="btn secondary" :disabled="saving" @click="modal = false">{{ t('cancel') }}</button>
<button class="btn primary" :disabled="saving">{{ saving ? t('saving') : t('save') }}</button>
</footer>
</form>
</div>
</Teleport>
</template>

View File

@@ -0,0 +1,97 @@
<script setup>
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { CalendarCheck2, AlarmClockOff, CalendarClock, Play, Check, Flag, ListTodo, TicketCheck, PartyPopper } from 'lucide-vue-next'
import { call, notifyTasksChanged, onTasksChanged } from '../api'
import TaskDetailModal from '../components/TaskDetailModal.vue'
// 今日任务页:聚合今天需要处理的待办与工单(逾期 / 今天到期 / 进行中 / 未来 7 天)。
const { t } = useI18n()
const todos = ref([])
const tickets = ref([])
const detail = ref(null)
let offTasks = null
let timer = 0
async function load() {
try {
;[todos.value, tickets.value] = await Promise.all([call('ListTodos', 'all', 0), call('ListTickets', 'all', 0)])
} catch { /* 后端未就绪时静默 */ }
}
const all = computed(() => [
...todos.value.filter(x => !['done'].includes(x.status)).map(x => ({ ...x, kind: 'todo' })),
...tickets.value.filter(x => !['resolved', 'closed'].includes(x.status)).map(x => ({ ...x, kind: 'ticket' }))
])
const byDue = (a, b) => (a.dueAt || '9999') < (b.dueAt || '9999') ? -1 : 1
const dayStr = d => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
const todayStr = dayStr(new Date())
const dueDay = x => (x.dueAt || '').slice(0, 10)
const isOverdue = x => x.dueAt && new Date(x.dueAt.length === 10 ? x.dueAt + 'T23:59' : x.dueAt) < new Date() && dueDay(x) !== todayStr
const isDoing = x => x.status === 'doing' || x.status === 'in_progress'
const within7 = x => {
const d = dueDay(x)
if (!d || d <= todayStr) return false
const limit = new Date()
limit.setDate(limit.getDate() + 7)
return d <= dayStr(limit)
}
// 每项只归入一个分区:逾期 > 今天到期 > 进行中 > 未来 7 天
const secOverdue = computed(() => all.value.filter(isOverdue).sort(byDue))
const secToday = computed(() => all.value.filter(x => !isOverdue(x) && dueDay(x) === todayStr).sort(byDue))
const secDoing = computed(() => all.value.filter(x => !isOverdue(x) && dueDay(x) !== todayStr && isDoing(x)).sort(byDue))
const secUpcoming = computed(() => all.value.filter(x => !isOverdue(x) && dueDay(x) !== todayStr && !isDoing(x) && within7(x)).sort(byDue))
const sections = computed(() => [
{ key: 'overdue', icon: AlarmClockOff, label: t('secOverdue'), items: secOverdue.value },
{ key: 'today', icon: CalendarCheck2, label: t('secToday'), items: secToday.value },
{ key: 'doing', icon: Play, label: t('secDoing'), items: secDoing.value },
{ key: 'upcoming', icon: CalendarClock, label: t('secUpcoming'), items: secUpcoming.value }
])
const total = computed(() => sections.value.reduce((n, s) => n + s.items.length, 0))
const actions = x => x.kind === 'todo'
? (x.status === 'open' ? [{ s: 'doing', label: t('taskStart'), icon: Play }, { s: 'done', label: t('taskDone'), icon: Check }] : [{ s: 'done', label: t('taskDone'), icon: Check }])
: (x.status === 'open' ? [{ s: 'in_progress', label: t('taskStart'), icon: Play }] : [{ s: 'resolved', label: t('taskResolve'), icon: Check }])
async function act(x, status) {
await call(x.kind === 'todo' ? 'SetTodoStatus' : 'SetTicketStatus', x.id, status)
await load()
notifyTasksChanged()
}
onMounted(() => {
load()
offTasks = onTasksChanged(load)
timer = setInterval(load, 60000)
})
onUnmounted(() => { offTasks?.(); clearInterval(timer) })
</script>
<template>
<div class="page today-page">
<header class="page-head sticky-head">
<div><h1>{{ t('todayTasks') }}</h1><p>{{ t('todaySubtitle') }}</p></div>
</header>
<div v-if="!total" class="today-empty"><PartyPopper />{{ t('todayEmpty') }}</div>
<template v-for="sec in sections" :key="sec.key">
<section v-if="sec.items.length" class="today-section" :class="sec.key">
<h2 class="today-title"><component :is="sec.icon" />{{ sec.label }}<em>{{ sec.items.length }}</em></h2>
<div class="panel today-panel">
<div v-for="x in sec.items" :key="x.kind + x.id" class="tc-item today-item" @click="detail = { kind: x.kind, item: x }">
<span class="tc-icon" :class="x.kind"><component :is="x.kind === 'todo' ? ListTodo : TicketCheck" /></span>
<div class="tc-main">
<b>{{ x.title }}</b>
<small>
<span v-if="x.projectName" class="tc-proj">{{ x.projectName }}</span>
<span v-if="x.priority === 'high'" class="tc-pri"><Flag />{{ t('priority.high') }}</span>
<time v-if="x.dueAt" :class="{ overdue: sec.key === 'overdue' }">{{ x.dueAt.replace('T', ' ').slice(5, 16) }}</time>
</small>
</div>
<div class="tc-acts">
<button v-for="a in actions(x)" :key="a.s" :title="a.label" @click.stop="act(x, a.s)"><component :is="a.icon" />{{ a.label }}</button>
</div>
</div>
</div>
</section>
</template>
<TaskDetailModal v-if="detail" :kind="detail.kind" :item="detail.item" @close="detail = null" @changed="load" />
</div>
</template>

View File

@@ -0,0 +1,206 @@
<script setup>
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { Plus, ListTodo, LayoutGrid, Rows3, Pencil, Trash2, RefreshCw, Circle, CircleDot, CircleCheck, Flag, CalendarDays, X, ImagePlus } from 'lucide-vue-next'
import { call, notifyTasksChanged, onTasksChanged } from '../api'
import { useAppStore } from '../store'
import MarkdownView from '../components/MarkdownView.vue'
import DueQuickPick from '../components/DueQuickPick.vue'
import LifecycleTimeline from '../components/LifecycleTimeline.vue'
import { useMdEditor } from '../mdeditor'
const route = useRoute()
const router = useRouter()
const { t } = useI18n()
const store = useAppStore()
const todos = ref([])
const view = ref(localStorage.getItem('cc-todo-view') || 'board')
const projectFilter = ref(0)
const quickTitle = ref('')
const modal = ref(false)
const saving = ref(false)
const error = ref('')
const form = reactive({ id: 0, title: '', content: '', projectId: 0, dueAt: '', priority: 'medium', status: 'open' })
const current = ref(null) // 正在编辑的原始条目(时间线展示已保存的轨迹)
const md = useMdEditor(form, 'content', e => { error.value = String(e) })
const statuses = ['open', 'doing', 'done']
const statusIcon = { open: Circle, doing: CircleDot, done: CircleCheck }
const filtered = computed(() => projectFilter.value ? todos.value.filter(x => x.projectId === projectFilter.value) : todos.value)
const columns = computed(() => statuses.map(s => ({ status: s, items: filtered.value.filter(x => x.status === s) })))
const overdue = x => x.dueAt && x.status !== 'done' && new Date(x.dueAt.length === 10 ? x.dueAt + 'T23:59' : x.dueAt) < new Date()
async function load() {
todos.value = await call('ListTodos', 'all', 0)
}
// 本页写入后广播(顶栏任务中心等同步刷新);也接收其它入口的变更
async function reloadAndNotify() {
await load()
notifyTasksChanged()
}
function setView(v) {
view.value = v
localStorage.setItem('cc-todo-view', v)
}
function openModal(x) {
Object.assign(form, x
? { id: x.id, title: x.title, content: x.content, projectId: x.projectId, dueAt: x.dueAt, priority: x.priority, status: x.status }
: { id: 0, title: '', content: '', projectId: projectFilter.value || 0, dueAt: '', priority: 'medium', status: 'open' })
current.value = x || null
error.value = ''
md.reset()
modal.value = true
}
async function save() {
if (saving.value) return
saving.value = true
error.value = ''
try {
await call('SaveTodo', { ...form, projectId: Number(form.projectId) || 0 })
modal.value = false
await reloadAndNotify()
} catch (e) {
const code = String(e).split(':')[0]
error.value = code === 'TODO_TITLE_REQUIRED' ? t('errors.TODO_TITLE_REQUIRED') : String(e)
} finally {
saving.value = false
}
}
async function quickAdd() {
const title = quickTitle.value.trim()
if (!title) return
quickTitle.value = ''
await call('SaveTodo', { id: 0, title, content: '', projectId: projectFilter.value || 0, dueAt: '', priority: 'medium', status: 'open' })
await reloadAndNotify()
}
async function setStatus(x, status) {
await call('SetTodoStatus', x.id, status)
await reloadAndNotify()
}
async function remove(x) {
if (!confirm(`${t('delete')} ${x.title}?`)) return
await call('DeleteTodo', x.id)
await reloadAndNotify()
}
function cycle(x) {
const next = { open: 'doing', doing: 'done', done: 'open' }[x.status]
setStatus(x, next)
}
// 深链 ?edit=id任务中心/日历详情点“编辑”后直接弹出编辑框
function openEditFromQuery() {
const id = Number(route.query.edit)
if (!id) return
router.replace('/todos')
const x = todos.value.find(i => i.id === id)
if (x) openModal(x)
}
onMounted(async () => {
await load()
openEditFromQuery()
})
watch(() => route.query.edit, v => { if (v) openEditFromQuery() })
const offTasks = onTasksChanged(load)
onUnmounted(() => offTasks())
</script>
<template>
<div class="page todos-page">
<header class="page-head sticky-head">
<div><h1>{{ t('todos') }}</h1><p>{{ t('todosSubtitle') }}</p></div>
<div class="actions">
<div class="tabs compact view-toggle">
<button :class="{ active: view === 'board' }" @click="setView('board')"><LayoutGrid />{{ t('boardView') }}</button>
<button :class="{ active: view === 'list' }" @click="setView('list')"><Rows3 />{{ t('listView') }}</button>
</div>
<button class="btn primary" @click="openModal()"><Plus />{{ t('addTodo') }}</button>
</div>
</header>
<div class="todo-toolbar">
<label class="search quick-add"><Plus /><input v-model="quickTitle" :placeholder="t('quickAddTodo')" @keyup.enter="quickAdd" /></label>
<select v-model.number="projectFilter" class="log-category">
<option :value="0">{{ t('allProjects') }}</option>
<option v-for="p in store.projects" :key="p.id" :value="p.id">{{ p.name }}</option>
</select>
<button class="btn secondary" @click="load"><RefreshCw />{{ t('refresh') }}</button>
</div>
<div v-if="view === 'board'" class="todo-board">
<section v-for="col in columns" :key="col.status" class="todo-column" :class="col.status">
<header><component :is="statusIcon[col.status]" /><b>{{ t('todoStatus.' + col.status) }}</b><span>{{ col.items.length }}</span></header>
<article v-for="x in col.items" :key="x.id" class="todo-card" :class="[x.priority, { overdue: overdue(x) }]">
<button class="todo-check" :title="t('todoStatus.' + x.status)" @click="cycle(x)"><component :is="statusIcon[x.status]" /></button>
<div class="todo-main" @click="openModal(x)">
<b :class="{ done: x.status === 'done' }">{{ x.title }}</b>
<!-- 内容与标题一字不差时不再重复展示摘要 -->
<MarkdownView v-if="x.content && x.content.trim() !== x.title.trim()" class="md-clamp" :source="x.content" />
<div class="todo-meta">
<span v-if="x.projectName" class="todo-chip">{{ x.projectName }}</span>
<span v-if="x.dueAt" class="todo-due" :class="{ overdue: overdue(x) }"><CalendarDays />{{ x.dueAt.replace('T', ' ') }}</span>
<span class="todo-priority" :class="x.priority"><Flag />{{ t('priority.' + x.priority) }}</span>
</div>
</div>
<button class="todo-remove" :title="t('delete')" @click="remove(x)"><Trash2 /></button>
</article>
<div v-if="!col.items.length" class="todo-empty">{{ t('empty') }}</div>
</section>
</div>
<section v-else class="panel todo-list-panel">
<div v-for="x in filtered" :key="x.id" class="todo-row" :class="[x.priority, { overdue: overdue(x) }]">
<button class="todo-check" @click="cycle(x)"><component :is="statusIcon[x.status]" /></button>
<b :class="{ done: x.status === 'done' }">{{ x.title }}</b>
<span v-if="x.projectName" class="todo-chip">{{ x.projectName }}</span>
<span class="todo-priority" :class="x.priority"><Flag />{{ t('priority.' + x.priority) }}</span>
<span class="todo-due" :class="{ overdue: overdue(x) }">{{ x.dueAt ? x.dueAt.replace('T', ' ') : '—' }}</span>
<div class="icon-actions">
<button :title="t('edit')" @click="openModal(x)"><Pencil /></button>
<button :title="t('delete')" @click="remove(x)"><Trash2 /></button>
</div>
</div>
<div v-if="!filtered.length" class="empty"><ListTodo />{{ t('noTodos') }}</div>
</section>
</div>
<Teleport to="body">
<div v-if="modal" class="overlay" @click.self="!saving && (modal = false)">
<form class="modal modal-split" @submit.prevent="save">
<header><h2>{{ form.id ? t('editTodo') : t('addTodo') }}</h2><button type="button" :disabled="saving" @click="modal = false"><X /></button></header>
<div class="split-body">
<div class="split-fields">
<label>{{ t('todoTitle') }}<input v-model="form.title" :disabled="saving" required /></label>
<label>{{ t('relatedProject') }}<select v-model.number="form.projectId" :disabled="saving"><option :value="0">{{ t('noProject') }}</option><option v-for="p in store.projects" :key="p.id" :value="p.id">{{ p.name }}</option></select></label>
<label>{{ t('dueDate') }}<input v-model="form.dueAt" type="datetime-local" :disabled="saving" />
<DueQuickPick v-model="form.dueAt" with-time :disabled="saving" />
</label>
<div class="field-pair">
<label>{{ t('priorityLabel') }}<select v-model="form.priority" :disabled="saving"><option value="low">{{ t('priority.low') }}</option><option value="medium">{{ t('priority.medium') }}</option><option value="high">{{ t('priority.high') }}</option></select></label>
<label>{{ t('statusLabel') }}<select v-model="form.status" :disabled="saving"><option v-for="s in statuses" :key="s" :value="s">{{ t('todoStatus.' + s) }}</option></select></label>
</div>
<LifecycleTimeline v-if="current" :history="current.history" :created-at="current.createdAt" :updated-at="current.updatedAt" :status="current.status" kind="todo" />
</div>
<div class="split-editor">
<div class="md-toolbar">
<span class="md-field-label">{{ t('todoContent') }}</span>
<div class="tabs compact">
<button type="button" :class="{ active: !md.preview.value }" @click="md.preview.value = false">{{ t('mdEdit') }}</button>
<button type="button" :class="{ active: md.preview.value }" @click="md.preview.value = true">{{ t('mdPreviewTab') }}</button>
</div>
<button type="button" class="btn secondary md-img-btn" :disabled="saving || md.uploading.value" @click="md.pickImage"><ImagePlus />{{ md.uploading.value ? t('mdInserting') : t('insertImage') }}</button>
</div>
<div class="md-editor">
<textarea v-show="!md.preview.value" :ref="md.inputEl" v-model="form.content" :disabled="saving" :placeholder="t('mdPlaceholder')" @paste="md.onPaste" />
<MarkdownView v-if="md.preview.value" class="md-preview-box" :source="form.content || t('mdEmpty')" />
</div>
</div>
</div>
<footer>
<p v-if="error" class="form-error">{{ error }}</p>
<button type="button" class="btn secondary" :disabled="saving" @click="modal = false">{{ t('cancel') }}</button>
<button class="btn primary" :disabled="saving">{{ saving ? t('saving') : t('save') }}</button>
</footer>
</form>
</div>
</Teleport>
</template>

View File

@@ -0,0 +1,172 @@
<script setup>
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { Star, Folder, Code2, GitCommitHorizontal, ListTodo, TicketCheck, StickyNote, ArrowRight, Circle, CircleDot, CircleCheck, Play, Check, CalendarDays, Plus, Bell } from 'lucide-vue-next'
import StatCard from '../components/StatCard.vue'
import AIDayPanel from '../components/AIDayPanel.vue'
import { call } from '../api'
import { useAppStore } from '../store'
const router = useRouter()
const store = useAppStore()
const { t } = useI18n()
const todos = ref([])
const tickets = ref([])
const note = ref(null)
const noteText = ref('')
const noteSavedAt = ref('')
const messages = ref([])
let noteTimer
const statusIcon = { open: Circle, doing: CircleDot, done: CircleCheck }
const fmt = n => {
n = +n || 0
return n >= 1e6 ? (n / 1e6).toFixed(1) + 'M' : n >= 1e3 ? (n / 1e3).toFixed(1) + 'K' : n.toString()
}
const favoriteProjects = computed(() => store.favorites.map(id => store.projects.find(p => p.id === id)).filter(Boolean))
const endOfWeek = () => {
const d = new Date()
d.setDate(d.getDate() + (7 - d.getDay()))
d.setHours(23, 59, 59, 0)
return d
}
const dueDate = v => new Date(v.length === 10 ? v + 'T23:59' : v)
const openTodos = computed(() => todos.value.filter(x => x.status !== 'done'))
const todayTodos = computed(() => {
const today = new Date().toISOString().slice(0, 10)
return openTodos.value.filter(x => x.dueAt && (x.dueAt.slice(0, 10) <= today))
})
const weekTickets = computed(() => tickets.value.filter(x =>
['open', 'in_progress'].includes(x.status) && x.dueAt && dueDate(x.dueAt) <= endOfWeek()
))
const overdue = v => v && dueDate(v) < new Date()
async function load() {
;[todos.value, tickets.value, messages.value] = await Promise.all([
call('ListTodos', 'all', 0),
call('ListTickets', 'all', 0),
call('ListMessages', 8)
])
note.value = await call('GetNote')
noteText.value = note.value.content
}
function editNote() {
clearTimeout(noteTimer)
noteTimer = setTimeout(async () => {
// 带 id 保存,避免笔记中心新建笔记后误写到"最近一条"
note.value = await call('SaveNoteByID', note.value?.id || 0, noteText.value)
noteSavedAt.value = new Date().toTimeString().slice(0, 8)
}, 600)
}
async function completeTodo(x) {
await call('SetTodoStatus', x.id, 'done')
todos.value = await call('ListTodos', 'all', 0)
}
async function advanceTicket(x) {
await call('SetTicketStatus', x.id, x.status === 'open' ? 'in_progress' : 'resolved')
tickets.value = await call('ListTickets', 'all', 0)
}
async function unfavorite(p) {
await store.toggleFavorite(p.id)
}
onMounted(load)
onUnmounted(() => clearTimeout(noteTimer))
</script>
<template>
<div class="page workbench-page">
<header class="page-head sticky-head">
<div><h1>{{ t('workbench') }}</h1><p>{{ t('workbenchSubtitle') }}</p></div>
<div class="actions">
<button class="btn secondary" @click="router.push('/projects')"><Folder />{{ t('projects') }}</button>
<button class="btn primary" @click="store.pendingAction = 'addProject'; router.push('/projects')"><Plus />{{ t('addProject') }}</button>
</div>
</header>
<div class="stats-grid four">
<StatCard :icon="Folder" :value="store.dashboard.projects" :label="t('totalProjects')" />
<StatCard :icon="Code2" tone="green" :value="fmt(store.dashboard.totalLines)" :label="t('totalLines')" />
<StatCard :icon="ListTodo" tone="blue" :value="openTodos.length" :label="t('openTodos')" />
<StatCard :icon="TicketCheck" tone="red" :value="tickets.filter(x => ['open', 'in_progress'].includes(x.status)).length" :label="t('openTickets')" />
</div>
<AIDayPanel />
<section class="panel wb-favorites shine-card">
<div class="section-head">
<h2><Star class="panel-icon star-icon" />{{ t('favoriteProjects') }}</h2>
<button class="btn secondary" @click="router.push('/projects')">{{ t('allProjects') }}<ArrowRight /></button>
</div>
<div v-if="favoriteProjects.length" class="wb-fav-grid">
<article v-for="p in favoriteProjects" :key="p.id" class="wb-fav-card" @click="router.push('/project/' + p.id)">
<div class="wb-fav-head">
<b>{{ p.name }}</b>
<button class="wb-star active" :title="t('unfavorite')" @click.stop="unfavorite(p)"><Star /></button>
</div>
<p :title="p.path">{{ p.path }}</p>
<footer>
<span><Code2 />{{ fmt(p.stats?.totalLines) }}</span>
<span><GitCommitHorizontal />{{ fmt(p.stats?.commitCount) }}</span>
</footer>
</article>
</div>
<div v-else class="empty wb-empty"><Star />{{ t('noFavorites') }}</div>
</section>
<div class="wb-grid">
<section class="panel wb-col">
<div class="section-head">
<h2><ListTodo class="panel-icon" />{{ t('todayTodos') }}<small>{{ todayTodos.length }}</small></h2>
<button class="btn secondary" @click="router.push('/todos')">{{ t('todos') }}<ArrowRight /></button>
</div>
<div class="wb-list">
<div v-for="x in todayTodos.slice(0, 8)" :key="x.id" class="wb-item" :class="x.priority">
<button class="todo-check" @click="completeTodo(x)"><component :is="statusIcon[x.status]" /></button>
<div><b>{{ x.title }}</b><small :class="{ 'overdue-text': overdue(x.dueAt) }"><CalendarDays />{{ x.dueAt.replace('T', ' ') }}</small></div>
<span v-if="x.projectName" class="todo-chip">{{ x.projectName }}</span>
</div>
<div v-if="!todayTodos.length" class="empty wb-empty">{{ t('noTodayTodos') }}</div>
</div>
</section>
<section class="panel wb-col">
<div class="section-head">
<h2><TicketCheck class="panel-icon" />{{ t('weekTickets') }}<small>{{ weekTickets.length }}</small></h2>
<button class="btn secondary" @click="router.push('/tickets')">{{ t('tickets') }}<ArrowRight /></button>
</div>
<div class="wb-list">
<div v-for="x in weekTickets.slice(0, 8)" :key="x.id" class="wb-item" :class="x.priority">
<span class="ticket-status wb-ticket-status" :class="x.status">{{ t('ticketStatus.' + x.status) }}</span>
<div><b>{{ x.title }}</b><small :class="{ 'overdue-text': overdue(x.dueAt) }"><CalendarDays />{{ x.dueAt.replace('T', ' ') }} · {{ x.projectName }}</small></div>
<button class="btn secondary flow-btn" @click="advanceTicket(x)">
<component :is="x.status === 'open' ? Play : Check" />{{ x.status === 'open' ? t('ticketFlow.start') : t('ticketFlow.resolve') }}
</button>
</div>
<div v-if="!weekTickets.length" class="empty wb-empty">{{ t('noWeekTickets') }}</div>
</div>
</section>
<section class="panel wb-col wb-note-panel">
<div class="section-head">
<h2><StickyNote class="panel-icon" />{{ t('notepad') }}</h2>
<small v-if="noteSavedAt" class="wb-note-saved">{{ t('autoSaved') }} {{ noteSavedAt }}</small>
</div>
<textarea v-model="noteText" class="wb-note" :placeholder="t('notepadPlaceholder')" @input="editNote" />
</section>
<section class="panel wb-col">
<div class="section-head">
<h2><Bell class="panel-icon" />{{ t('recentMessages') }}<small>{{ messages.length }}</small></h2>
<button class="btn secondary" @click="router.push('/messages')">{{ t('viewAll') }}<ArrowRight /></button>
</div>
<div class="wb-msg-list">
<div v-for="m in messages.slice(0, 8)" :key="m.id" class="wb-msg" :class="{ unread: !m.read }">
<b>{{ m.title }}</b><time>{{ m.createdAt?.replace('T', ' ').slice(5, 16) }}</time>
</div>
<div v-if="!messages.length" class="empty wb-empty">{{ t('noMessages') }}</div>
</div>
</section>
</div>
</div>
</template>