269 lines
14 KiB
Vue
269 lines
14 KiB
Vue
<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>
|