Files
code-utils/frontend/src/views/TeamHome.vue
2026-08-14 07:52:01 +08:00

269 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>