更新若干功能
This commit is contained in:
BIN
frontend/public/favicon.png
Normal file
BIN
frontend/public/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
BIN
frontend/src/assets/logo.png
Normal file
BIN
frontend/src/assets/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
66
frontend/src/components/AIBrief.vue
Normal file
66
frontend/src/components/AIBrief.vue
Normal 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>
|
||||
92
frontend/src/components/AIDayPanel.vue
Normal file
92
frontend/src/components/AIDayPanel.vue
Normal 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>
|
||||
101
frontend/src/components/AIScopeDrawer.vue
Normal file
101
frontend/src/components/AIScopeDrawer.vue
Normal 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_briefs(projectID=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>
|
||||
53
frontend/src/components/AboutModal.vue
Normal file
53
frontend/src/components/AboutModal.vue
Normal 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>
|
||||
103
frontend/src/components/CommandPalette.vue
Normal file
103
frontend/src/components/CommandPalette.vue
Normal 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>
|
||||
141
frontend/src/components/DailyCard.vue
Normal file
141
frontend/src/components/DailyCard.vue
Normal 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>
|
||||
125
frontend/src/components/DatePicker.vue
Normal file
125
frontend/src/components/DatePicker.vue
Normal 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>
|
||||
74
frontend/src/components/DueQuickPick.vue
Normal file
74
frontend/src/components/DueQuickPick.vue
Normal 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>
|
||||
123
frontend/src/components/FilePreviewModal.vue
Normal file
123
frontend/src/components/FilePreviewModal.vue
Normal 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>
|
||||
96
frontend/src/components/LifecycleTimeline.vue
Normal file
96
frontend/src/components/LifecycleTimeline.vue
Normal 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>
|
||||
117
frontend/src/components/LoginModal.vue
Normal file
117
frontend/src/components/LoginModal.vue
Normal 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>
|
||||
59
frontend/src/components/MarkdownView.vue
Normal file
59
frontend/src/components/MarkdownView.vue
Normal 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>
|
||||
79
frontend/src/components/MessageBell.vue
Normal file
79
frontend/src/components/MessageBell.vue
Normal 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>
|
||||
74
frontend/src/components/NoteCenter.vue
Normal file
74
frontend/src/components/NoteCenter.vue
Normal 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>
|
||||
74
frontend/src/components/NoteModal.vue
Normal file
74
frontend/src/components/NoteModal.vue
Normal 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>
|
||||
225
frontend/src/components/ProjectAIDrawer.vue
Normal file
225
frontend/src/components/ProjectAIDrawer.vue
Normal 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>
|
||||
105
frontend/src/components/TaskCenter.vue
Normal file
105
frontend/src/components/TaskCenter.vue
Normal 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>
|
||||
140
frontend/src/components/TaskDetailModal.vue
Normal file
140
frontend/src/components/TaskDetailModal.vue
Normal 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>
|
||||
68
frontend/src/components/TeamSwitcher.vue
Normal file
68
frontend/src/components/TeamSwitcher.vue
Normal 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
31
frontend/src/dateutil.js
Normal 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
151
frontend/src/eggart.js
Normal 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
53
frontend/src/mdeditor.js
Normal 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' : ''}\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
56
frontend/src/team.js
Normal 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 key(errors.XXX),未识别返回空。
|
||||
export function teamErrCode(e) {
|
||||
return String(e).split(':')[0].trim()
|
||||
}
|
||||
268
frontend/src/views/AIChat.vue
Normal file
268
frontend/src/views/AIChat.vue
Normal 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>
|
||||
504
frontend/src/views/CalendarPage.vue
Normal file
504
frontend/src/views/CalendarPage.vue
Normal 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>
|
||||
237
frontend/src/views/Launchpad.vue
Normal file
237
frontend/src/views/Launchpad.vue
Normal 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>
|
||||
82
frontend/src/views/Messages.vue
Normal file
82
frontend/src/views/Messages.vue
Normal 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>
|
||||
66
frontend/src/views/Notes.vue
Normal file
66
frontend/src/views/Notes.vue
Normal 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>
|
||||
515
frontend/src/views/Profile.vue
Normal file
515
frontend/src/views/Profile.vue
Normal 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)
|
||||
}
|
||||
// 后端存 UTC(RFC3339),展示时转为本地时区
|
||||
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)
|
||||
// 存储走向由后端按管理员全局配置实时决定(auto):server 时上传返回 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>
|
||||
268
frontend/src/views/TeamHome.vue
Normal file
268
frontend/src/views/TeamHome.vue
Normal 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>
|
||||
173
frontend/src/views/TeamReports.vue
Normal file
173
frontend/src/views/TeamReports.vue
Normal 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>
|
||||
234
frontend/src/views/TeamTasks.vue
Normal file
234
frontend/src/views/TeamTasks.vue
Normal 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>
|
||||
193
frontend/src/views/Tickets.vue
Normal file
193
frontend/src/views/Tickets.vue
Normal 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>
|
||||
97
frontend/src/views/Today.vue
Normal file
97
frontend/src/views/Today.vue
Normal 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>
|
||||
206
frontend/src/views/Todos.vue
Normal file
206
frontend/src/views/Todos.vue
Normal 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>
|
||||
172
frontend/src/views/Workbench.vue
Normal file
172
frontend/src/views/Workbench.vue
Normal 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>
|
||||
Reference in New Issue
Block a user