Files
code-utils/frontend/src/views/TeamHome.vue

269 lines
14 KiB
Vue
Raw Normal View History

2026-08-14 07:52:01 +08:00
<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>