更新若干功能
This commit is contained in:
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