feat(duty): add web-admin calendar page and public H5 grab UI

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zlwuandCursor committed 2026-08-14 17:14:05 +08:00
1 parent 36fc7f286c
commit 35e09fa09b
9 files changed
+3152

No files matched your search

+71
View File
@@ -0,0 +1,71 @@
import request from '@/utils/http'
export type ShiftType = 'morning' | 'evening' | 'duty'
export type LinkStatus = 'open' | 'closed'
export interface DutyAssignmentItem {
id?: string
source: 'assign' | 'grab'
userId?: string | null
personName: string
}
export interface DutySlotItem {
id?: string
shiftType: ShiftType
shiftLabel?: string
capacity: number
taken?: number
assignments: DutyAssignmentItem[]
}
export interface DutyDayItem {
id?: string | null
dutyDate: string
linkStatus: LinkStatus
accessToken?: string | null
publicUrl?: string | null
slots: DutySlotItem[]
}
export function fetchDutyMonth(year: number, month: number) {
return request.get<DutyDayItem[]>({
url: '/web/admin/duty',
params: { year, month }
})
}
export function fetchDutyDay(dutyDate: string) {
return request.get<DutyDayItem>({
url: '/web/admin/duty/day',
params: { dutyDate }
})
}
export function saveDutyDay(data: {
dutyDate: string
slots: Array<{
shiftType: ShiftType
capacity: number
assignments: DutyAssignmentItem[]
}>
linkStatus?: LinkStatus
}) {
return request.post<DutyDayItem>({
url: '/web/admin/duty/day',
data
})
}
export function setDutyLinkStatus(dayId: string, linkStatus: LinkStatus) {
return request.post<DutyDayItem>({
url: `/web/admin/duty/day/${dayId}/link`,
data: { linkStatus }
})
}
export function deleteDutyAssignment(id: string) {
return request.delete({
url: `/web/admin/duty/assignments/${id}`
})
}
+32
View File
@@ -0,0 +1,32 @@
import { publicPlGet, publicPlPost } from '@/utils/http/public-pl'
import type { ShiftType } from '@/api/duty'
export interface PublicDutySlot {
id?: string
shiftType: ShiftType
shiftLabel?: string
capacity: number
taken: number
assignments: Array<{ id?: string; source: string; personName: string }>
}
export interface PublicDutyPage {
dutyDate: string
linkStatus: 'open' | 'closed'
closed?: boolean
slots: PublicDutySlot[]
}
export interface PublicDutyGrabResult {
dutyDate: string
shiftType: ShiftType
shiftLabel: string
personName: string
status: string
}
export const publicDutyApi = {
getPage: (token: string) => publicPlGet<PublicDutyPage>(`/public/duty/${token}`),
grab: (token: string, data: { shiftType: string; personName: string }) =>
publicPlPost<PublicDutyGrabResult>(`/public/duty/${token}/grab`, data)
}
@@ -87,6 +87,12 @@ export const staticRoutes: AppRouteRecordRaw[] = [
component: () => import('@views/public/db/inventory/index.vue'),
meta: { title: '出入库单', isHideTab: true, isPublicMobile: true }
},
{
path: '/public/duty/:token',
name: 'PublicDutyGrab',
component: () => import('@views/public/duty/index.vue'),
meta: { title: '值班抢班', isHideTab: true, isPublicMobile: true }
},
{
path: '/:pathMatch(.*)*',
name: 'Exception404CatchAll',
+673
View File
@@ -0,0 +1,673 @@
<template>
<div class="page-container duty-schedule-page">
<section class="panel">
<div class="panel-top">
<h2 class="title-bar-heading">值班排班</h2>
<div class="month-nav">
<el-button class="btn-ghost" @click="shiftMonth(-1)">
<el-icon><ArrowLeft /></el-icon>
</el-button>
<span class="month-label">{{ year }}年 {{ month }}月</span>
<el-button class="btn-ghost" @click="shiftMonth(1)">
<el-icon><ArrowRight /></el-icon>
</el-button>
<el-button class="btn-ghost" @click="goToday">
<el-icon><Calendar /></el-icon>
今天
</el-button>
</div>
<div class="legend">
<span><i class="dot am" />早班</span>
<span><i class="dot pm" />晚班</span>
<span><i class="dot duty" />值班</span>
<span class="muted">点击日期配置 / 复制链接</span>
</div>
</div>
<div v-loading="loading" class="cal-wrap">
<table class="duty-cal">
<thead>
<tr>
<th class="wknd">日</th>
<th>一</th>
<th>二</th>
<th>三</th>
<th>四</th>
<th>五</th>
<th class="wknd">六</th>
</tr>
</thead>
<tbody>
<tr v-for="(week, wi) in weeks" :key="wi">
<td
v-for="(cell, di) in week"
:key="di"
:class="{
muted: !cell.inMonth,
today: cell.isToday,
'has-day': cell.inMonth
}"
@click="cell.inMonth && openDay(cell.dateStr)"
>
<div class="day-num">{{ cell.day }}</div>
<template v-if="cell.inMonth && cell.summary">
<span
v-for="(chip, ci) in cell.chips"
:key="ci"
class="chip"
:class="chip.cls"
>
{{ chip.text }}
</span>
<span v-if="!cell.summary.slots?.length" class="chip empty">未排班 · 点击配置</span>
</template>
<span v-else-if="cell.inMonth" class="chip empty">未排班 · 点击配置</span>
</td>
</tr>
</tbody>
</table>
</div>
</section>
<el-dialog
v-model="dialogVisible"
class="dialog-form-layout duty-day-dialog"
:title="`配置 · ${editingDate}`"
width="640px"
align-center
destroy-on-close
:close-on-click-modal="false"
>
<el-form label-position="top" class="duty-day-form">
<p class="form-tip">
为当天配置班次名额,保存后生成公开链接。可直接从系统用户安排,也可留给员工抢班。
</p>
<div v-for="slot in formSlots" :key="slot.shiftType" class="slot-card">
<div class="slot-hd">
<el-checkbox v-model="slot.enabled">{{ shiftLabel(slot.shiftType) }}</el-checkbox>
<div class="cap-field" v-if="slot.enabled">
<span>名额</span>
<el-input-number v-model="slot.capacity" :min="1" :max="99" controls-position="right" />
</div>
</div>
<template v-if="slot.enabled">
<el-form-item label="直接安排(系统用户)">
<el-select
v-model="slot.pendingUserId"
filterable
clearable
placeholder="选择员工后添加"
style="width: 100%"
@change="(v) => addAssign(slot, String(v || ''))"
>
<el-option
v-for="u in userOptions"
:key="u.value"
:label="u.label"
:value="u.value"
/>
</el-select>
</el-form-item>
<div class="person-tags">
<el-tag
v-for="(a, ai) in slot.assignments"
:key="a.id || `${a.source}-${a.personName}-${ai}`"
:type="a.source === 'grab' ? 'warning' : 'success'"
closable
size="small"
@close="removeAssign(slot, ai)"
>
{{ a.personName }}({{ a.source === 'grab' ? '抢班' : '指定' }})
</el-tag>
<span v-if="!slot.assignments.length" class="empty-tip">暂无报名</span>
</div>
</template>
</div>
<div v-if="dayDetail?.publicUrl" class="link-box">
<span>公开链接</span>
<code>{{ dayDetail.publicUrl }}</code>
<el-button class="btn-main" type="primary" size="small" @click="copyLink">
<el-icon><CopyDocument /></el-icon>
复制
</el-button>
<el-button
v-if="dayDetail.id"
class="btn-ghost"
size="small"
@click="toggleLink"
>
<el-icon><SwitchButton /></el-icon>
{{ dayDetail.linkStatus === 'closed' ? '打开链接' : '关闭链接' }}
</el-button>
</div>
</el-form>
<template #footer>
<el-button class="btn-ghost" @click="dialogVisible = false">
<el-icon><Close /></el-icon>
取消
</el-button>
<el-button class="btn-main" type="primary" :loading="saving" @click="handleSave">
<el-icon><Check /></el-icon>
保存
</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import {
ArrowLeft,
ArrowRight,
Calendar,
Check,
Close,
CopyDocument,
SwitchButton
} from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { fetchUserOptions } from '@/api/admin'
import {
deleteDutyAssignment,
fetchDutyDay,
fetchDutyMonth,
saveDutyDay,
setDutyLinkStatus,
type DutyDayItem,
type DutySlotItem,
type ShiftType
} from '@/api/duty'
defineOptions({ name: 'DutySchedule' })
const SHIFT_TYPES: ShiftType[] = ['morning', 'evening', 'duty']
const SHIFT_LABELS: Record<ShiftType, string> = {
morning: '早班',
evening: '晚班',
duty: '值班'
}
const SHIFT_CLS: Record<ShiftType, string> = {
morning: 'am',
evening: 'pm',
duty: 'duty'
}
const now = new Date()
const year = ref(now.getFullYear())
const month = ref(now.getMonth() + 1)
const loading = ref(false)
const saving = ref(false)
const monthDays = ref<DutyDayItem[]>([])
const dialogVisible = ref(false)
const editingDate = ref('')
const dayDetail = ref<DutyDayItem | null>(null)
const userOptions = ref<Array<{ label: string; value: string }>>([])
type FormSlot = {
shiftType: ShiftType
enabled: boolean
capacity: number
pendingUserId: string
assignments: DutySlotItem['assignments']
}
const formSlots = reactive<FormSlot[]>(
SHIFT_TYPES.map((t) => ({
shiftType: t,
enabled: false,
capacity: 1,
pendingUserId: '',
assignments: []
}))
)
function shiftLabel(t: ShiftType) {
return SHIFT_LABELS[t]
}
function pad(n: number) {
return n < 10 ? `0${n}` : String(n)
}
function toDateStr(y: number, m: number, d: number) {
return `${y}-${pad(m)}-${pad(d)}`
}
const dayMap = computed(() => {
const map = new Map<string, DutyDayItem>()
for (const d of monthDays.value) {
if (d.dutyDate) map.set(d.dutyDate, d)
}
return map
})
const weeks = computed(() => {
const y = year.value
const m = month.value
const first = new Date(y, m - 1, 1)
const startWeekday = first.getDay()
const daysInMonth = new Date(y, m, 0).getDate()
const todayStr = toDateStr(now.getFullYear(), now.getMonth() + 1, now.getDate())
const cells: Array<{
day: number
inMonth: boolean
dateStr: string
isToday: boolean
summary?: DutyDayItem
chips: Array<{ text: string; cls: string }>
}> = []
const prevDays = new Date(y, m - 1, 0).getDate()
for (let i = 0; i < startWeekday; i++) {
const d = prevDays - startWeekday + i + 1
cells.push({ day: d, inMonth: false, dateStr: '', isToday: false, chips: [] })
}
for (let d = 1; d <= daysInMonth; d++) {
const dateStr = toDateStr(y, m, d)
const summary = dayMap.value.get(dateStr)
const chips: Array<{ text: string; cls: string }> = []
if (summary?.slots?.length) {
for (const s of summary.slots) {
const names = (s.assignments || []).map((a) => a.personName).filter(Boolean)
const taken = s.taken ?? names.length
const text = names.length
? `${s.shiftLabel || SHIFT_LABELS[s.shiftType]} ${names.slice(0, 2).join('、')}${names.length > 2 ? '…' : ''}`
: `${s.shiftLabel || SHIFT_LABELS[s.shiftType]} ${taken}/${s.capacity}`
chips.push({ text, cls: SHIFT_CLS[s.shiftType] || 'am' })
}
}
cells.push({
day: d,
inMonth: true,
dateStr,
isToday: dateStr === todayStr,
summary,
chips
})
}
while (cells.length % 7 !== 0) {
cells.push({
day: cells.length % 7,
inMonth: false,
dateStr: '',
isToday: false,
chips: []
})
}
const rows: (typeof cells)[] = []
for (let i = 0; i < cells.length; i += 7) rows.push(cells.slice(i, i + 7))
return rows
})
async function loadMonth() {
loading.value = true
try {
const res = await fetchDutyMonth(year.value, month.value)
monthDays.value = Array.isArray(res) ? res : []
} catch (e: any) {
ElMessage.error(e?.message || '加载月历失败')
monthDays.value = []
} finally {
loading.value = false
}
}
function shiftMonth(delta: number) {
let y = year.value
let m = month.value + delta
if (m < 1) {
m = 12
y -= 1
} else if (m > 12) {
m = 1
y += 1
}
year.value = y
month.value = m
void loadMonth()
}
function goToday() {
year.value = now.getFullYear()
month.value = now.getMonth() + 1
void loadMonth()
}
function resetFormSlots(detail: DutyDayItem | null) {
for (const fs of formSlots) {
const found = detail?.slots?.find((s) => s.shiftType === fs.shiftType)
fs.enabled = !!found
fs.capacity = found?.capacity || 1
fs.pendingUserId = ''
fs.assignments = found ? [...(found.assignments || [])] : []
}
if (!detail?.slots?.length) {
// 默认启用早班晚班各 1
formSlots[0].enabled = true
formSlots[0].capacity = 1
formSlots[1].enabled = true
formSlots[1].capacity = 1
}
}
async function openDay(dateStr: string) {
editingDate.value = dateStr
dialogVisible.value = true
try {
const detail = (await fetchDutyDay(dateStr)) as DutyDayItem
dayDetail.value = detail
resetFormSlots(detail)
} catch (e: any) {
ElMessage.error(e?.message || '加载失败')
dayDetail.value = { dutyDate: dateStr, linkStatus: 'open', slots: [] }
resetFormSlots(null)
}
}
function addAssign(slot: FormSlot, userId: string) {
if (!userId) return
const u = userOptions.value.find((x) => x.value === userId)
if (!u) return
if (slot.assignments.some((a) => a.userId === userId || a.personName === u.label)) {
ElMessage.warning('该员工已在名单中')
slot.pendingUserId = ''
return
}
if (slot.assignments.length >= slot.capacity) {
ElMessage.warning('已达名额上限')
slot.pendingUserId = ''
return
}
slot.assignments.push({
source: 'assign',
userId,
personName: u.label
})
slot.pendingUserId = ''
}
async function removeAssign(slot: FormSlot, index: number) {
const a = slot.assignments[index]
if (a?.id) {
try {
await ElMessageBox.confirm('确认删除该报名?', '提示', { type: 'warning' })
await deleteDutyAssignment(a.id)
} catch {
return
}
}
slot.assignments.splice(index, 1)
}
async function handleSave() {
const slots = formSlots
.filter((s) => s.enabled)
.map((s) => ({
shiftType: s.shiftType,
capacity: s.capacity,
assignments: s.assignments
.filter((a) => a.source === 'assign')
.map((a) => ({
source: 'assign' as const,
userId: a.userId || undefined,
personName: a.personName
}))
}))
if (!slots.length) {
ElMessage.warning('请至少启用一个班次')
return
}
saving.value = true
try {
const detail = (await saveDutyDay({
dutyDate: editingDate.value,
slots,
linkStatus: dayDetail.value?.linkStatus || 'open'
})) as DutyDayItem
dayDetail.value = detail
ElMessage.success('保存成功')
await loadMonth()
resetFormSlots(detail)
} catch (e: any) {
ElMessage.error(e?.message || '保存失败')
} finally {
saving.value = false
}
}
async function copyLink() {
const url = dayDetail.value?.publicUrl
if (!url) return
try {
await navigator.clipboard.writeText(url)
ElMessage.success('已复制链接')
} catch {
ElMessage.info(url)
}
}
async function toggleLink() {
if (!dayDetail.value?.id) return
const next = dayDetail.value.linkStatus === 'closed' ? 'open' : 'closed'
try {
dayDetail.value = (await setDutyLinkStatus(dayDetail.value.id, next)) as DutyDayItem
ElMessage.success(next === 'closed' ? '已关闭链接' : '已打开链接')
await loadMonth()
} catch (e: any) {
ElMessage.error(e?.message || '操作失败')
}
}
onMounted(async () => {
try {
const opts = await fetchUserOptions()
userOptions.value = (Array.isArray(opts) ? opts : []) as Array<{
label: string
value: string
}>
} catch {
userOptions.value = []
}
await loadMonth()
})
</script>
<style scoped lang="scss">
.duty-schedule-page {
.panel-top {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px 16px;
margin-bottom: 16px;
}
.month-nav {
display: flex;
align-items: center;
gap: 8px;
}
.month-label {
min-width: 110px;
text-align: center;
font-weight: 600;
}
.legend {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-left: auto;
font-size: 12px;
color: var(--mint-text-muted, #64748b);
}
.legend .muted {
color: var(--mint-text-muted, #94a3b8);
}
.dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 4px;
vertical-align: middle;
}
.dot.am {
background: #f59e0b;
}
.dot.pm {
background: #3b82f6;
}
.dot.duty {
background: #ef4444;
}
.duty-cal {
width: 100%;
table-layout: fixed;
border-collapse: separate;
border-spacing: 0;
border: 1px solid var(--mint-border, #d9e8e2);
border-radius: 8px;
overflow: hidden;
}
.duty-cal th {
padding: 10px;
font-size: 12px;
font-weight: 500;
color: var(--mint-text-muted, #64748b);
background: #f8fafc;
border-bottom: 1px solid var(--mint-border, #d9e8e2);
}
.duty-cal th.wknd {
color: #dc2626;
}
.duty-cal td {
height: 108px;
padding: 6px;
vertical-align: top;
border-right: 1px solid var(--mint-border, #d9e8e2);
border-bottom: 1px solid var(--mint-border, #d9e8e2);
background: #fff;
cursor: default;
}
.duty-cal tr td:last-child {
border-right: none;
}
.duty-cal tr:last-child td {
border-bottom: none;
}
.duty-cal td.has-day {
cursor: pointer;
}
.duty-cal td.has-day:hover {
background: #f0fdf4;
}
.duty-cal td.muted {
background: #fafafa;
color: #94a3b8;
}
.day-num {
margin-bottom: 4px;
font-size: 12px;
font-weight: 600;
}
.duty-cal td.today .day-num {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
color: #fff;
background: var(--el-color-primary, #10b981);
border-radius: 50%;
}
.chip {
display: block;
margin-bottom: 3px;
padding: 2px 6px;
overflow: hidden;
font-size: 11px;
line-height: 1.3;
text-overflow: ellipsis;
white-space: nowrap;
border-radius: 4px;
}
.chip.am {
color: #b45309;
background: #fffbeb;
}
.chip.pm {
color: #1d4ed8;
background: #eff6ff;
}
.chip.duty {
color: #b91c1c;
background: #fef2f2;
}
.chip.empty {
color: var(--mint-text-muted, #64748b);
background: #f1f5f9;
border: 1px dashed #cbd5e1;
}
}
.duty-day-form {
.form-tip {
margin: 0 0 12px;
font-size: 13px;
color: var(--mint-text-muted, #64748b);
}
.slot-card {
margin-bottom: 12px;
padding: 12px;
background: #fafafa;
border: 1px solid var(--mint-border, #d9e8e2);
border-radius: 8px;
}
.slot-hd {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.cap-field {
display: flex;
gap: 8px;
align-items: center;
font-size: 13px;
}
.person-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 4px;
}
.empty-tip {
font-size: 12px;
color: var(--mint-text-muted, #94a3b8);
}
.link-box {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
margin-top: 12px;
padding: 10px 12px;
font-size: 12px;
background: var(--el-color-primary-light-9, #ecfdf5);
border: 1px solid #a7f3d0;
border-radius: 8px;
}
.link-box code {
flex: 1;
min-width: 160px;
word-break: break-all;
color: var(--el-color-primary-dark-2, #059669);
}
}
</style>
+454
View File
@@ -0,0 +1,454 @@
<template>
<div class="duty-h5">
<div v-if="loading" class="state">加载中…</div>
<div v-else-if="errorType === 'invalid'" class="state">
<div class="icon">?</div>
<h2>链接无效或已失效</h2>
<p>请确认是否复制完整,或向管理员重新索取当日值班链接。</p>
</div>
<div v-else-if="errorType === 'closed' || page?.linkStatus === 'closed' || page?.closed" class="state">
<div class="icon off">⊘</div>
<h2>报名已关闭</h2>
<p>管理员已关闭本链接,暂时无法继续抢班。</p>
<div v-if="page?.dutyDate" class="card">
<div class="kv"><span>日期</span><span>{{ page.dutyDate }}</span></div>
</div>
</div>
<div v-else-if="success" class="page">
<header class="header">
<div class="eyebrow">山峡化工 · 值班抢班</div>
<h1>报名成功</h1>
<p class="sub">请按时到岗。如需调整请联系管理员。</p>
</header>
<div class="body state-ok">
<div class="icon ok">✓</div>
<h2>已抢到{{ success.shiftLabel }}</h2>
<p>{{ success.dutyDate }}</p>
<div class="card">
<div class="kv"><span>日期</span><span>{{ success.dutyDate }}</span></div>
<div class="kv"><span>班次</span><span>{{ success.shiftLabel }}</span></div>
<div class="kv"><span>姓名</span><span>{{ success.personName }}</span></div>
</div>
<button type="button" class="btn secondary" @click="backToList">返回查看当日班次</button>
</div>
</div>
<div v-else-if="page" class="page">
<header class="header">
<div class="eyebrow">山峡化工 · 值班抢班</div>
<h1>{{ formatTitle(page.dutyDate) }}</h1>
<p class="sub">无需登录,填写姓名后选择班次提交。名额满了就不能再抢。</p>
<div class="meta">
<span class="pill">{{ weekdayLabel(page.dutyDate) }}</span>
<span class="pill">链接有效</span>
<span class="pill">剩余 {{ remainTotal }} 个名额</span>
</div>
</header>
<div class="body">
<div class="tip">提示:同名可能重复,请填写真实姓名;提交后如需改动请联系管理员。</div>
<section v-for="slot in page.slots" :key="slot.shiftType" class="slot">
<div class="slot-hd">
<div class="title">
<i class="dot" :class="slot.shiftType" />
{{ slot.shiftLabel || slot.shiftType }}
</div>
<span class="badge" :class="isFull(slot) ? 'full' : 'open'">
{{ isFull(slot) ? '已满' : '可抢' }}
</span>
</div>
<div class="cap">
<span>名额</span>
<strong>{{ slot.taken }} / {{ slot.capacity }}</span>
</div>
<div class="bar"><i :style="{ width: barWidth(slot) }" :class="slot.shiftType" /></div>
<div class="people">
<span v-for="(a, i) in slot.assignments" :key="i" class="person">{{ a.personName }}</span>
<span v-if="!slot.assignments?.length" class="person empty">暂无人报名</span>
<span v-else-if="slot.capacity - slot.taken > 0" class="person empty"
>空缺 {{ slot.capacity - slot.taken }}</span
>
</div>
<template v-if="!isFull(slot)">
<label class="field">
<span>您的姓名</span>
<input v-model="names[slot.shiftType]" maxlength="20" placeholder="请输入真实姓名" />
</label>
<button type="button" class="btn" :disabled="submitting" @click="grab(slot)">
提交抢班
</button>
</template>
<button v-else type="button" class="btn" disabled>名额已满</button>
</section>
</div>
<footer class="note">提交即表示确认当日可到岗值班<br />链接由管理员分享 · 请勿转发无关人员</footer>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { useRoute } from 'vue-router'
import {
publicDutyApi,
type PublicDutyGrabResult,
type PublicDutyPage,
type PublicDutySlot
} from '@/api/public-duty'
defineOptions({ name: 'PublicDutyGrab' })
const route = useRoute()
const loading = ref(true)
const submitting = ref(false)
const errorType = ref<'invalid' | 'closed' | ''>('')
const page = ref<PublicDutyPage | null>(null)
const success = ref<PublicDutyGrabResult | null>(null)
const names = reactive<Record<string, string>>({})
const token = computed(() => String(route.params.token || ''))
const remainTotal = computed(() => {
if (!page.value?.slots) return 0
return page.value.slots.reduce((sum, s) => sum + Math.max(0, s.capacity - s.taken), 0)
})
function isFull(slot: PublicDutySlot) {
return (slot.taken || 0) >= (slot.capacity || 0)
}
function barWidth(slot: PublicDutySlot) {
if (!slot.capacity) return '0%'
return `${Math.min(100, Math.round((slot.taken / slot.capacity) * 100))}%`
}
function formatTitle(dutyDate: string) {
const d = dutyDate?.split('-')
if (!d || d.length < 3) return '值班'
return `${Number(d[1])}月${Number(d[2])}日 值班`
}
function weekdayLabel(dutyDate: string) {
const map = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
const dt = new Date(dutyDate.replace(/-/g, '/'))
if (Number.isNaN(dt.getTime())) return ''
return map[dt.getDay()]
}
async function load() {
loading.value = true
errorType.value = ''
success.value = null
try {
const data = await publicDutyApi.getPage(token.value)
page.value = data
if (data.linkStatus === 'closed' || data.closed) {
errorType.value = 'closed'
}
for (const s of data.slots || []) {
if (names[s.shiftType] === undefined) names[s.shiftType] = ''
}
} catch (e: any) {
const msg = String(e?.message || e || '')
if (msg.includes('关闭')) errorType.value = 'closed'
else errorType.value = 'invalid'
page.value = null
} finally {
loading.value = false
}
}
async function grab(slot: PublicDutySlot) {
const name = (names[slot.shiftType] || '').trim()
if (!name) {
window.alert('请先填写姓名')
return
}
submitting.value = true
try {
const res = await publicDutyApi.grab(token.value, {
shiftType: slot.shiftType,
personName: name
})
success.value = res
names[slot.shiftType] = ''
} catch (e: any) {
window.alert(e?.message || '提交失败')
await load()
} finally {
submitting.value = false
}
}
function backToList() {
success.value = null
void load()
}
onMounted(() => {
void load()
})
</script>
<style scoped lang="scss">
.duty-h5 {
min-height: 100vh;
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Helvetica Neue', sans-serif;
color: #1f2a26;
background: #f5f8f7;
}
.page {
max-width: 480px;
margin: 0 auto;
}
.header {
padding: 28px 18px 22px;
color: #fff;
background: linear-gradient(160deg, #0a9f6d 0%, #087f57 100%);
border-radius: 0 0 22px 22px;
}
.eyebrow {
margin-bottom: 6px;
font-size: 12px;
opacity: 0.88;
}
h1 {
margin: 0;
font-size: 22px;
font-weight: 700;
}
.sub {
margin: 8px 0 0;
font-size: 13px;
line-height: 1.45;
opacity: 0.92;
}
.meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 14px;
}
.pill {
padding: 4px 10px;
font-size: 11px;
background: rgba(255, 255, 255, 0.18);
border: 1px solid rgba(255, 255, 255, 0.22);
border-radius: 999px;
}
.body {
padding: 14px 14px 28px;
}
.tip {
margin-bottom: 12px;
padding: 10px 12px;
font-size: 12px;
line-height: 1.5;
color: #8a6418;
background: #fff7e8;
border: 1px solid #f0dfb5;
border-radius: 12px;
}
.slot {
margin-bottom: 12px;
padding: 14px;
background: #fff;
border: 1px solid #dcebe4;
border-radius: 16px;
box-shadow: 0 1px 2px rgba(31, 42, 38, 0.04);
}
.slot-hd {
display: flex;
align-items: center;
justify-content: space-between;
}
.title {
display: flex;
gap: 8px;
align-items: center;
font-size: 16px;
font-weight: 700;
}
.dot {
width: 10px;
height: 10px;
border-radius: 50%;
}
.dot.morning {
background: #cf8a19;
}
.dot.evening {
background: #3b82f6;
}
.dot.duty {
background: #d14343;
}
.badge {
padding: 3px 8px;
font-size: 11px;
font-weight: 600;
border-radius: 999px;
}
.badge.open {
color: #087f57;
background: #e9f9f2;
}
.badge.full {
color: #6b7b74;
background: #eef2f0;
}
.cap {
display: flex;
align-items: baseline;
justify-content: space-between;
margin-top: 10px;
font-size: 12px;
color: #6b7b74;
}
.cap strong {
font-size: 15px;
color: #1f2a26;
}
.bar {
height: 7px;
margin-top: 8px;
overflow: hidden;
background: #e8f0ec;
border-radius: 999px;
}
.bar > i {
display: block;
height: 100%;
background: #0a9f6d;
border-radius: 999px;
}
.bar > i.morning {
background: #cf8a19;
}
.bar > i.evening {
background: #3b82f6;
}
.bar > i.duty {
background: #d14343;
}
.people {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 10px;
}
.person {
padding: 4px 9px;
font-size: 12px;
color: #6b7b74;
background: #f3f7f5;
border: 1px solid #dcebe4;
border-radius: 999px;
}
.person.empty {
color: #8a9a93;
background: transparent;
border-style: dashed;
}
.field {
display: block;
margin-top: 12px;
}
.field span {
display: block;
margin-bottom: 6px;
font-size: 12px;
color: #6b7b74;
}
.field input {
width: 100%;
height: 44px;
padding: 0 12px;
font-size: 15px;
background: #fff;
border: 1px solid #dcebe4;
border-radius: 12px;
outline: none;
}
.btn {
width: 100%;
height: 44px;
margin-top: 10px;
font-size: 15px;
font-weight: 700;
color: #fff;
cursor: pointer;
background: #0a9f6d;
border: none;
border-radius: 12px;
}
.btn:disabled {
cursor: not-allowed;
background: #b7c5bf;
}
.btn.secondary {
max-width: 280px;
color: #087f57;
background: #fff;
border: 1px solid #0a9f6d;
}
.note {
padding: 4px 16px 24px;
font-size: 11px;
line-height: 1.5;
color: #8a9a93;
text-align: center;
}
.state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 70vh;
padding: 40px 28px;
text-align: center;
}
.state-ok {
display: flex;
flex-direction: column;
align-items: center;
padding-top: 28px;
text-align: center;
}
.icon {
display: flex;
align-items: center;
justify-content: center;
width: 72px;
height: 72px;
margin-bottom: 16px;
font-size: 34px;
color: #0a9f6d;
background: #e9f9f2;
border-radius: 50%;
}
.icon.off {
color: #6b7b74;
background: #eef2f0;
}
.card {
width: 100%;
max-width: 320px;
margin-top: 22px;
padding: 14px;
text-align: left;
background: #fff;
border: 1px solid #dcebe4;
border-radius: 14px;
}
.kv {
display: flex;
justify-content: space-between;
padding: 6px 0;
font-size: 13px;
}
.kv span:first-child {
color: #6b7b74;
}
.kv span:last-child {
font-weight: 600;
}
</style>