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',
|
||||
name: 'screen-results',
|
||||
component: () => import('../views/VoteResultsView.vue'),
|
||||
meta: { title: '年会大屏 - 投票结果' },
|
||||
meta: { title: '年会大屏 - 结果展示' },
|
||||
},
|
||||
{
|
||||
path: '/screen/lottery-results',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 ? '已连接' : '连接中...' }}
|
||||
|
||||
Reference in New Issue
Block a user