feat(admin): enhance program config with CRUD and reorder

- Change "结果展示" to "投票结果" in global controls
- Add program edit modal with name, team, performer, remark fields
- Add move up/down buttons for program reordering
- Add delete program with confirmation
- Add "添加节目" button for creating new programs
- Add responsive styles for mobile view

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
empty
2026-02-03 23:52:29 +08:00
parent 83bf1d3a43
commit 7fea6b8578
3 changed files with 453 additions and 9 deletions

View File

@@ -43,7 +43,7 @@ const router = createRouter({
path: '/screen/results',
name: 'screen-results',
component: () => import('../views/VoteResultsView.vue'),
meta: { title: '年会大屏 - 投票结果' },
meta: { title: '年会大屏 - 结果展示' },
},
{
path: '/screen/lottery-results',

View File

@@ -77,6 +77,18 @@ function showToast(message: string, type: 'error' | 'success' | 'info' = 'info',
}, duration);
}
// Program editing state
const showProgramModal = ref(false);
const editingProgram = ref<any>(null);
const programForm = ref({
id: '',
name: '',
teamName: '',
performer: '',
remark: '',
});
const programSaving = ref(false);
// Prize configuration state
const showPrizeConfig = ref(false);
const prizeConfigLoading = ref(false);
@@ -255,9 +267,132 @@ async function loadParticipants() {
}
}
// Navigation
function goBack() {
router.push('/');
// Program management functions
function openProgramEdit(program: any) {
editingProgram.value = program;
programForm.value = {
id: program.id,
name: program.name || '',
teamName: program.teamName || '',
performer: program.performer || '',
remark: program.remark || '',
};
showProgramModal.value = true;
}
function openAddProgram() {
editingProgram.value = null;
programForm.value = {
id: '',
name: '',
teamName: '',
performer: '',
remark: '',
};
showProgramModal.value = true;
}
function closeProgramModal() {
showProgramModal.value = false;
editingProgram.value = null;
}
async function saveProgramsToServer(programs: any[]) {
const res = await fetch('/api/admin/programs', {
method: 'PUT',
headers: getAdminHeaders(),
body: JSON.stringify({ programs }),
});
const data = await readJsonSafe(res);
if (!res.ok) {
throw new Error(data?.error || data?.message || `保存节目配置失败(${res.status})`);
}
return data;
}
async function saveProgram() {
programSaving.value = true;
try {
const programs = [...admin.programs];
if (editingProgram.value) {
// Edit existing program
const index = programs.findIndex(p => p.id === editingProgram.value.id);
if (index !== -1) {
programs[index] = {
...programs[index],
name: programForm.value.name,
teamName: programForm.value.teamName,
performer: programForm.value.performer,
remark: programForm.value.remark,
};
}
} else {
// Add new program
const newId = `program_${Date.now()}`;
programs.push({
id: newId,
name: programForm.value.name,
teamName: programForm.value.teamName,
performer: programForm.value.performer,
remark: programForm.value.remark,
status: 'pending',
order: programs.length + 1,
});
}
await saveProgramsToServer(programs);
showToast(editingProgram.value ? '节目已更新' : '节目已添加', 'success');
closeProgramModal();
} catch (e) {
const message = (e as Error).message;
showToast(`保存失败: ${message}`, 'error');
} finally {
programSaving.value = false;
}
}
async function deleteProgram(programId: string) {
if (!confirm('确定要删除这个节目吗?')) return;
try {
const programs = admin.programs.filter(p => p.id !== programId);
await saveProgramsToServer(programs);
showToast('节目已删除', 'success');
} catch (e) {
const message = (e as Error).message;
showToast(`删除失败: ${message}`, 'error');
}
}
async function moveProgram(programId: string, direction: 'up' | 'down') {
const programs = [...admin.programs];
const index = programs.findIndex(p => p.id === programId);
if (index === -1) return;
const newIndex = direction === 'up' ? index - 1 : index + 1;
if (newIndex < 0 || newIndex >= programs.length) return;
// Swap positions
[programs[index], programs[newIndex]] = [programs[newIndex], programs[index]];
// Update order field
programs.forEach((p, i) => {
p.order = i + 1;
});
try {
await saveProgramsToServer(programs);
showToast('顺序已调整', 'success');
} catch (e) {
const message = (e as Error).message;
showToast(`调整失败: ${message}`, 'error');
}
}
// Logout and go to login page
function handleLogout() {
localStorage.removeItem(ADMIN_TOKEN_KEY);
router.push('/admin/login');
}
// Phase control
@@ -551,7 +686,7 @@ onMounted(() => {
<div class="admin-control">
<!-- Header -->
<header class="header">
<button class="back-btn" @click="goBack"> 返回</button>
<button class="back-btn" @click="handleLogout"> 退出</button>
<h1 class="title">管理控制台</h1>
<div class="status-bar">
<span class="status-item">
@@ -862,6 +997,53 @@ onMounted(() => {
</div>
</details>
<!-- Section: Program Configuration -->
<details class="control-section program-section collapsible-section">
<summary class="section-header">
<h2>🎬 节目配置</h2>
<button class="add-program-btn" @click.stop="openAddProgram">+ 添加节目</button>
</summary>
<div class="section-body">
<div v-if="admin.programs.length === 0" class="no-stats">
暂无节目数据
</div>
<div v-else class="program-config-list">
<div v-for="(program, index) in admin.programs" :key="program.id" class="program-config-item">
<span class="program-order">{{ index + 1 }}</span>
<span class="program-name">{{ program.name }}</span>
<span class="program-team">{{ program.teamName }}</span>
<span class="program-status" :class="program.status">
{{ program.status === 'current' ? '进行中' : program.status === 'completed' ? '已完成' : '待演出' }}
</span>
<div class="program-actions">
<button
class="action-btn"
:disabled="index === 0"
@click="moveProgram(program.id, 'up')"
title="上移"
></button>
<button
class="action-btn"
:disabled="index === admin.programs.length - 1"
@click="moveProgram(program.id, 'down')"
title="下移"
></button>
<button
class="action-btn edit"
@click="openProgramEdit(program)"
title="编辑"
></button>
<button
class="action-btn delete"
@click="deleteProgram(program.id)"
title="删除"
>🗑</button>
</div>
</div>
</div>
</div>
</details>
<!-- Section C: Global Controls -->
<section class="control-section global-section">
<div class="section-header">
@@ -899,7 +1081,7 @@ onMounted(() => {
:class="{ active: admin.systemPhase === 'RESULTS' }"
@click="setPhase('RESULTS')"
>
结果展示
投票结果
</button>
<button
class="ctrl-btn"
@@ -1021,7 +1203,7 @@ onMounted(() => {
<div class="modal cleanup-modal">
<h3>高级数据清理</h3>
<p>选择要清理的数据类型和存储层</p>
<div class="cleanup-sections">
<!-- Lottery Cleanup Options -->
<div class="cleanup-section">
@@ -1080,6 +1262,44 @@ onMounted(() => {
</div>
</div>
</div>
<!-- Program Edit Modal -->
<div v-if="showProgramModal" class="modal-overlay" @click.self="closeProgramModal">
<div class="modal program-modal">
<div class="modal-header">
<h3>{{ editingProgram ? '编辑节目' : '添加节目' }}</h3>
<button class="close-btn" @click="closeProgramModal">×</button>
</div>
<div class="modal-body">
<div class="form-group">
<label>节目名称</label>
<input v-model="programForm.name" type="text" placeholder="请输入节目名称" />
</div>
<div class="form-group">
<label>团队名称</label>
<input v-model="programForm.teamName" type="text" placeholder="请输入团队名称" />
</div>
<div class="form-group">
<label>表演者</label>
<input v-model="programForm.performer" type="text" placeholder="请输入表演者" />
</div>
<div class="form-group">
<label>备注</label>
<input v-model="programForm.remark" type="text" placeholder="请输入备注(可选)" />
</div>
</div>
<div class="modal-actions">
<button class="ctrl-btn outline" @click="closeProgramModal">取消</button>
<button
class="ctrl-btn primary"
:disabled="!programForm.name || programSaving"
@click="saveProgram"
>
{{ programSaving ? '保存中...' : '保存' }}
</button>
</div>
</div>
</div>
</div>
</template>
@@ -2090,11 +2310,40 @@ $admin-danger: #ef4444;
.control-section {
&.lottery-section,
&.global-section,
&.stats-section {
&.stats-section,
&.program-section {
grid-column: span 1;
}
}
// Program Section Mobile
.program-config-item {
grid-template-columns: 32px 1fr 80px;
gap: 8px;
padding: 10px 12px;
.program-team {
display: none;
}
.program-status {
font-size: 11px;
padding: 3px 8px;
}
}
.program-actions {
grid-column: span 3;
justify-content: flex-end;
margin-top: 8px;
}
.program-order {
width: 24px;
height: 24px;
font-size: 12px;
}
// Stats Section Mobile
.award-stats-grid {
grid-template-columns: 1fr;
@@ -2484,4 +2733,199 @@ $admin-danger: #ef4444;
color: #888;
padding: 20px;
}
// Program Configuration Styles
.program-section {
grid-column: span 2;
}
.add-program-btn {
padding: 6px 12px;
background: $admin-primary;
border: none;
border-radius: 6px;
color: white;
font-size: 12px;
cursor: pointer;
transition: all 0.2s;
&:hover {
background: lighten($admin-primary, 5%);
}
}
.program-config-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.program-config-item {
display: grid;
grid-template-columns: 40px 1fr 120px 80px auto;
gap: 12px;
align-items: center;
padding: 12px 16px;
background: rgba(255, 255, 255, 0.02);
border: 1px solid $admin-border;
border-radius: 8px;
transition: background 0.2s;
&:hover {
background: rgba(255, 255, 255, 0.04);
}
}
.program-order {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
background: rgba($color-gold, 0.2);
color: $color-gold;
border-radius: 6px;
font-size: 13px;
font-weight: 600;
}
.program-name {
font-size: 14px;
font-weight: 500;
color: $admin-text;
}
.program-team {
font-size: 13px;
color: $admin-text-muted;
}
.program-status {
padding: 4px 10px;
border-radius: 4px;
font-size: 12px;
text-align: center;
&.pending {
background: rgba(#888, 0.2);
color: #888;
}
&.current {
background: rgba($admin-warning, 0.2);
color: $admin-warning;
}
&.completed {
background: rgba($admin-success, 0.2);
color: $admin-success;
}
}
.program-actions {
display: flex;
gap: 6px;
}
.action-btn {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
background: #252525;
border: 1px solid $admin-border;
border-radius: 6px;
color: $admin-text-muted;
font-size: 12px;
cursor: pointer;
transition: all 0.2s;
&:hover:not(:disabled) {
background: #333;
color: $admin-text;
}
&:disabled {
opacity: 0.3;
cursor: not-allowed;
}
&.edit:hover:not(:disabled) {
border-color: $admin-primary;
color: $admin-primary;
}
&.delete:hover:not(:disabled) {
border-color: $admin-danger;
color: $admin-danger;
}
}
// Program Modal Styles
.program-modal {
width: 450px;
max-width: 90vw;
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
h3 {
margin: 0;
font-size: 18px;
}
.close-btn {
background: none;
border: none;
color: $admin-text-muted;
font-size: 24px;
cursor: pointer;
padding: 0;
line-height: 1;
&:hover {
color: $admin-text;
}
}
}
.modal-body {
display: flex;
flex-direction: column;
gap: 16px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 6px;
label {
font-size: 13px;
color: $admin-text-muted;
}
input {
padding: 10px 12px;
background: #252525;
border: 1px solid $admin-border;
border-radius: 6px;
color: $admin-text;
font-size: 14px;
&:focus {
outline: none;
border-color: $admin-primary;
}
&::placeholder {
color: #555;
}
}
}
}
</style>

View File

@@ -81,7 +81,7 @@ onMounted(() => {
<!-- Header -->
<header class="header">
<button class="back-btn" @click="goBack"> 返回</button>
<h1 class="title gold-text">投票结果</h1>
<h1 class="title gold-text">结果展示</h1>
<div class="status-indicator">
<span class="dot" :class="admin.isConnected ? 'online' : 'offline'"></span>
{{ admin.isConnected ? '已连接' : '连接中...' }}