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:
@@ -43,7 +43,7 @@ const router = createRouter({
|
|||||||
path: '/screen/results',
|
path: '/screen/results',
|
||||||
name: 'screen-results',
|
name: 'screen-results',
|
||||||
component: () => import('../views/VoteResultsView.vue'),
|
component: () => import('../views/VoteResultsView.vue'),
|
||||||
meta: { title: '年会大屏 - 投票结果' },
|
meta: { title: '年会大屏 - 结果展示' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/screen/lottery-results',
|
path: '/screen/lottery-results',
|
||||||
|
|||||||
@@ -77,6 +77,18 @@ function showToast(message: string, type: 'error' | 'success' | 'info' = 'info',
|
|||||||
}, duration);
|
}, 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
|
// Prize configuration state
|
||||||
const showPrizeConfig = ref(false);
|
const showPrizeConfig = ref(false);
|
||||||
const prizeConfigLoading = ref(false);
|
const prizeConfigLoading = ref(false);
|
||||||
@@ -255,9 +267,132 @@ async function loadParticipants() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Navigation
|
// Program management functions
|
||||||
function goBack() {
|
function openProgramEdit(program: any) {
|
||||||
router.push('/');
|
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
|
// Phase control
|
||||||
@@ -551,7 +686,7 @@ onMounted(() => {
|
|||||||
<div class="admin-control">
|
<div class="admin-control">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<header class="header">
|
<header class="header">
|
||||||
<button class="back-btn" @click="goBack">← 返回</button>
|
<button class="back-btn" @click="handleLogout">← 退出</button>
|
||||||
<h1 class="title">管理控制台</h1>
|
<h1 class="title">管理控制台</h1>
|
||||||
<div class="status-bar">
|
<div class="status-bar">
|
||||||
<span class="status-item">
|
<span class="status-item">
|
||||||
@@ -862,6 +997,53 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</details>
|
</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 C: Global Controls -->
|
||||||
<section class="control-section global-section">
|
<section class="control-section global-section">
|
||||||
<div class="section-header">
|
<div class="section-header">
|
||||||
@@ -899,7 +1081,7 @@ onMounted(() => {
|
|||||||
:class="{ active: admin.systemPhase === 'RESULTS' }"
|
:class="{ active: admin.systemPhase === 'RESULTS' }"
|
||||||
@click="setPhase('RESULTS')"
|
@click="setPhase('RESULTS')"
|
||||||
>
|
>
|
||||||
结果展示
|
投票结果
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="ctrl-btn"
|
class="ctrl-btn"
|
||||||
@@ -1021,7 +1203,7 @@ onMounted(() => {
|
|||||||
<div class="modal cleanup-modal">
|
<div class="modal cleanup-modal">
|
||||||
<h3>高级数据清理</h3>
|
<h3>高级数据清理</h3>
|
||||||
<p>选择要清理的数据类型和存储层:</p>
|
<p>选择要清理的数据类型和存储层:</p>
|
||||||
|
|
||||||
<div class="cleanup-sections">
|
<div class="cleanup-sections">
|
||||||
<!-- Lottery Cleanup Options -->
|
<!-- Lottery Cleanup Options -->
|
||||||
<div class="cleanup-section">
|
<div class="cleanup-section">
|
||||||
@@ -1080,6 +1262,44 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -2090,11 +2310,40 @@ $admin-danger: #ef4444;
|
|||||||
.control-section {
|
.control-section {
|
||||||
&.lottery-section,
|
&.lottery-section,
|
||||||
&.global-section,
|
&.global-section,
|
||||||
&.stats-section {
|
&.stats-section,
|
||||||
|
&.program-section {
|
||||||
grid-column: span 1;
|
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
|
// Stats Section Mobile
|
||||||
.award-stats-grid {
|
.award-stats-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
@@ -2484,4 +2733,199 @@ $admin-danger: #ef4444;
|
|||||||
color: #888;
|
color: #888;
|
||||||
padding: 20px;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ onMounted(() => {
|
|||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<header class="header">
|
<header class="header">
|
||||||
<button class="back-btn" @click="goBack">← 返回</button>
|
<button class="back-btn" @click="goBack">← 返回</button>
|
||||||
<h1 class="title gold-text">投票结果</h1>
|
<h1 class="title gold-text">结果展示</h1>
|
||||||
<div class="status-indicator">
|
<div class="status-indicator">
|
||||||
<span class="dot" :class="admin.isConnected ? 'online' : 'offline'"></span>
|
<span class="dot" :class="admin.isConnected ? 'online' : 'offline'"></span>
|
||||||
{{ admin.isConnected ? '已连接' : '连接中...' }}
|
{{ admin.isConnected ? '已连接' : '连接中...' }}
|
||||||
|
|||||||
Reference in New Issue
Block a user