打分系统工作台部分页面
This commit is contained in:
parent
d3cb65b065
commit
c799752f63
59
scripts/account-management-contract-test.mjs
Normal file
59
scripts/account-management-contract-test.mjs
Normal file
@ -0,0 +1,59 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
const read = (path) => readFileSync(resolve(process.cwd(), path), 'utf8');
|
||||
const accountApi = read('src/api/match/account.js');
|
||||
const unitApi = read('src/api/match/unitaccount.js');
|
||||
const page = read('src/views/permission/account/index.vue');
|
||||
const scoringDialog = read('src/views/permission/account/scoring-dialog.vue');
|
||||
const sysUserService = read('../YD_Event.Admin/YD_Event.Application/User/SysUserService.cs');
|
||||
const unitService = read('../YD_Event.Admin/YD_Event.Application/Unit/UnitAccountService.cs');
|
||||
const deploymentSql = read('../YD_Event.Admin/二期需求/dbo.sql');
|
||||
const menuSeed = JSON.parse(read('../YD_Event.Admin/YD_Event.Core/InitData/sysmenu.txt'));
|
||||
const roleMenuSeed = JSON.parse(read('../YD_Event.Admin/YD_Event.Core/InitData/sysrolemenu.txt'));
|
||||
|
||||
const checks = [
|
||||
['报名账号查询', accountApi, "http.get('/sysuser/page'"],
|
||||
['报名账号新增', accountApi, "http.post('/sysuser/user'"],
|
||||
['报名账号编辑', accountApi, "http.put('/sysuser/user'"],
|
||||
['报名账号删除', accountApi, "http.delete('/sysuser'"],
|
||||
['报名账号状态', accountApi, "http.post('/sysuser/setstatus'"],
|
||||
['报名账号重置密码', accountApi, "http.post('/sysuser/reset'"],
|
||||
['打分账号查询', unitApi, "http.get('/unitaccount/page'"],
|
||||
['打分账号新增', unitApi, "http.post('/unitaccount/add'"],
|
||||
['打分账号编辑', unitApi, "http.put('/unitaccount/edit'"],
|
||||
['打分账号删除', unitApi, "http.delete('/unitaccount/delete'"],
|
||||
['打分账号状态', unitApi, "http.post('/unitaccount/setStatus'"],
|
||||
['打分账号重置密码', unitApi, "http.post('/unitaccount/resetPassword'"],
|
||||
['报名平台页签', page, 'label="报名平台"'],
|
||||
['打分系统页签', page, 'label="打分系统"'],
|
||||
['打分查询固定平台', page, 'Platform: SCORING_PLATFORM'],
|
||||
['打分禁用确认', page, "confirmDisable('打分系统')"],
|
||||
['新增打分账号固定平台', scoringDialog, 'platform: SCORING_PLATFORM'],
|
||||
['新增打分账号自动角色', unitService, 'RoleId = GetRoleId(input.Platform)'],
|
||||
['报名账号数据域过滤', sysUserService, 'x.UnitPlatform == UnitAccountPlatform.EventSystem'],
|
||||
['报名账号详情隔离', sysUserService, 'await GetEventSystemUserAsync(id)'],
|
||||
['报名账号状态隔离', sysUserService, 'public override async Task SetStatus'],
|
||||
['单位接口阻止普通账号', unitService, '该账号不是单位账号'],
|
||||
['全局账号查重', unitService, 'x.Account == input.Account'],
|
||||
['打分账号密码加盐', unitService, '_idGenerator.Encode(user.Id)'],
|
||||
['上线SQL挂载打分账号按钮', deploymentSql, "(1081,N'打分账号-新增',2,N'system:unit:add',1047"],
|
||||
['上线SQL停用旧单位菜单', deploymentSql, "WHERE Id=1080 AND Code=N'system:unit' AND DeleteMark=0"],
|
||||
['上线SQL校验账号管理父级', deploymentSql, 'ParentId=1047 AND DeleteMark=0)=5'],
|
||||
];
|
||||
|
||||
for (const [name, source, expected] of checks) {
|
||||
assert.ok(source.includes(expected), `${name}:未找到 ${expected}`);
|
||||
process.stdout.write(`PASS ${name}\n`);
|
||||
}
|
||||
|
||||
const scoringButtonIds = new Set([1081, 1082, 1083, 1084, 1085]);
|
||||
const scoringButtons = menuSeed.filter((item) => scoringButtonIds.has(item.Id));
|
||||
assert.equal(scoringButtons.length, 5, '初始化菜单:打分账号按钮数量应为 5');
|
||||
assert.ok(scoringButtons.every((item) => item.ParentId === 1047), '初始化菜单:打分账号按钮必须挂到账号管理');
|
||||
assert.ok(!menuSeed.some((item) => item.Id === 1080), '初始化菜单:不应再包含单位管理菜单');
|
||||
assert.ok(!roleMenuSeed.some((item) => item.MenuId === 1080), '初始化授权:不应再授权单位管理菜单');
|
||||
process.stdout.write('PASS 初始化菜单与角色授权一致性\n');
|
||||
|
||||
process.stdout.write(`账号管理契约测试通过:${checks.length + 1}/${checks.length + 1}\n`);
|
||||
61
scripts/workbench-business-contract-test.mjs
Normal file
61
scripts/workbench-business-contract-test.mjs
Normal file
@ -0,0 +1,61 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
const read = (path) => readFileSync(resolve(process.cwd(), path), 'utf8').replace(/\r\n/g, '\n');
|
||||
const controller = read('../YD_Event.Admin/YD_Event.Application/Workbench/WorkbenchController.cs');
|
||||
const dtos = read('../YD_Event.Admin/YD_Event.Application/Workbench/Dtos/WorkbenchDtos.cs');
|
||||
const orderBook = read('../YD_Event.Admin/YD_Event.Application/OrderBook/OrderBookService.cs');
|
||||
const calculation = read('../YD_Event.Admin/YD_Event.Application/Event/Scoring/EventScoreCalculationService.cs');
|
||||
const sql = read('../YD_Event.Admin/二期需求/dbo.sql');
|
||||
const launcher = read('src/views/scoring/launcher/index.vue');
|
||||
const workbench = read('src/views/scoring/workbench/index.vue');
|
||||
const orderBookPage = read('src/views/scoring/orderbook/index.vue');
|
||||
const scoreScreen = read('src/views/scoring/screen/index.vue');
|
||||
const checkinScreen = read('src/views/scoring/checkin-screen/index.vue');
|
||||
const menuSeed = read('../YD_Event.Admin/YD_Event.Core/InitData/sysmenu.txt');
|
||||
const roleMenuSeed = read('../YD_Event.Admin/YD_Event.Core/InitData/sysrolemenu.txt');
|
||||
const backEndRoutes = read('src/router/backEnd.ts');
|
||||
|
||||
const checks = [
|
||||
['工作台接口不重复控制器前缀', controller, '[HttpGet("events")]'],
|
||||
['秩序册接口使用文档约定路由', orderBook, 'Name = "order-book"'],
|
||||
['投屏校验成绩与出场关联', controller, 'result.SessionEntryId != entry.Id'],
|
||||
['投屏校验成绩版本', controller, 'input.ResultVersion.Value != result.ResultVersion'],
|
||||
['投屏校验评分单收齐', controller, 'submittedJudgeCount < expectedJudgeCount'],
|
||||
['投屏后进入状态30', controller, 'result.ResultStatus = 30'],
|
||||
['成绩主表与版本表状态同步', controller, 'SyncResultVersionStatusAsync(result, 30'],
|
||||
['更正成绩确认后进入40', controller, 'ScoreCorrectionId.HasValue == true ? 40 : 20'],
|
||||
['聚合跳过成绩同步作废当前版本', calculation, 'version.ResultStatus = ResultStatusVoided'],
|
||||
['排名排除DQ', controller, '!r.IsDisqualified'],
|
||||
['排名不包含作废状态', controller, 'r.ResultStatus == 20 || r.ResultStatus == 30 || r.ResultStatus == 40'],
|
||||
['投屏操作事务化', controller, '[UnitOfWork]\n public async Task<ResultScreenCastOutput> CastResult'],
|
||||
['重赛按组别定位末位', controller, 'e.GroupName == originalEntry.GroupName'],
|
||||
['重赛后续出场顺延', controller, 'laterEntry.PlayOrder++'],
|
||||
['批量确认必须显式选择', controller, 'results.Count != requestedIds.Count'],
|
||||
['原因服务端规范化', controller, 'NormalizeReason(input.VoidReason, "作废原因")'],
|
||||
['动态评分项接口', dtos, 'List<WorkbenchScoreItemDto> ScoreItems'],
|
||||
['秩序册支持xls和xlsx', orderBook, 'ext is not ".xlsx" and not ".xls"'],
|
||||
['单一当前投屏唯一索引', sql, 'UX_EventResultScreenCast_Current'],
|
||||
['SQL状态30为已投屏', sql, "WHEN 30 THEN N''已投屏''"],
|
||||
['报名与秩序册数据源', launcher, "switchSource('orderbook')"],
|
||||
['赛事列表分页', launcher, 'v-model:current-page="pageNo"'],
|
||||
['工作台保存赛事上下文', launcher, "sessionStorage.setItem('scoring-context'"],
|
||||
['秩序册具有独立页面', orderBookPage, 'orderBookImportPost'],
|
||||
['成绩管理与投屏使用独立菜单路由', menuSeed, '"Path": "/scoring/cast"'],
|
||||
['秩序册直达路由具有兼容兜底', backEndRoutes, 'ensureScoringOperationalRoutes'],
|
||||
['超级管理员具有秩序册菜单授权', roleMenuSeed, '"MenuId": 1088'],
|
||||
['工作台动态评分列', workbench, 'v-for="col in detailColumns"'],
|
||||
['规定赛隐藏难度', scoreScreen, "includes('PRESCRIBED')"],
|
||||
['评分卡列数随规则动态调整', scoreScreen, 'Math.max(scoreCards.length,1)'],
|
||||
['成绩轮询防重入', scoreScreen, 'castRequesting'],
|
||||
['检录轮询防重入', checkinScreen, 'requesting=false'],
|
||||
['检录时间使用服务端基线', checkinScreen, 'serverBaseMs+(Date.now()-syncedAtMs)'],
|
||||
];
|
||||
|
||||
for (const [name, source, expected] of checks) {
|
||||
assert.ok(source.includes(expected), `${name}:缺少 ${expected}`);
|
||||
process.stdout.write(`PASS ${name}\n`);
|
||||
}
|
||||
|
||||
process.stdout.write(`工作台业务契约测试通过:${checks.length}/${checks.length}\n`);
|
||||
@ -72,8 +72,8 @@ export const unitaccountEditPut = (data) => {
|
||||
* @param {number} data.id - 单位账号ID
|
||||
* @param {string} data.password - 新密码 (6-32位)
|
||||
*/
|
||||
export const unitaccountResetPasswordPatch = (data) => {
|
||||
return http.patch('/unitaccount/resetPassword', data);
|
||||
export const unitaccountResetPasswordPost = (data) => {
|
||||
return http.post('/unitaccount/resetPassword', data);
|
||||
};
|
||||
|
||||
/**
|
||||
@ -82,8 +82,8 @@ export const unitaccountResetPasswordPatch = (data) => {
|
||||
* @param {number} data.id - 单位账号ID
|
||||
* @param {number} data.status - 状态 (0=启用, 1=禁用)
|
||||
*/
|
||||
export const unitaccountSetstatusPatch = (data) => {
|
||||
return http.patch('/unitaccount/setStatus', data);
|
||||
export const unitaccountSetstatusPost = (data) => {
|
||||
return http.post('/unitaccount/setStatus', data);
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
60
src/api/match/workbench.js
Normal file
60
src/api/match/workbench.js
Normal file
@ -0,0 +1,60 @@
|
||||
import { http } from '../../utils/request';
|
||||
|
||||
export const workbenchEventsGet = (params) => http.get('/workbench/events', { params });
|
||||
|
||||
export const workbenchSessionsGet = (params) => http.get('/workbench/sessions', { params });
|
||||
|
||||
export const workbenchSessionEntriesGet = (sessionId, params = {}) =>
|
||||
http.get(`/workbench/sessions/${sessionId}/entries`, { params });
|
||||
|
||||
export const workbenchScreenCastPost = (data) => http.post('/workbench/screencast', data);
|
||||
|
||||
export const workbenchScreenCastCurrentGet = (params) =>
|
||||
http.get('/workbench/screencast/current', { params });
|
||||
|
||||
export const workbenchScreenCastWithdrawPost = (screenCastId) =>
|
||||
http.post('/workbench/screencast/withdraw', { screenCastId });
|
||||
|
||||
export const workbenchCorrectionsGet = (params) => http.get('/workbench/corrections', { params });
|
||||
|
||||
export const workbenchCorrectionDetailGet = (correctionId) =>
|
||||
http.get(`/workbench/corrections/${correctionId}`);
|
||||
|
||||
export const workbenchCorrectionApprovePost = (data) =>
|
||||
http.post('/workbench/corrections/approve', data);
|
||||
|
||||
export const workbenchResultConfirmPost = (resultId) =>
|
||||
http.post('/workbench/results/confirm', { resultId });
|
||||
|
||||
export const workbenchResultsBatchConfirmPost = (data) =>
|
||||
http.post('/workbench/results/batch-confirm', data);
|
||||
|
||||
export const workbenchEntryRematchPost = (data) =>
|
||||
http.post('/workbench/entries/rematch', data);
|
||||
|
||||
export const workbenchResultVoidPost = (data) => http.post('/workbench/results/void', data);
|
||||
|
||||
export const judgeScoreCorrectionInitiatePost = (data) =>
|
||||
http.post('/judge-score/judge-scores/corrections/initiate', data);
|
||||
|
||||
export const checkinScreenCastGet = (params) => http.get('/checkin/screen-cast', { params });
|
||||
|
||||
export const workbenchResultsExportGet = (params) =>
|
||||
http.get('/workbench/results/export', { params, responseType: 'blob' });
|
||||
|
||||
export const workbenchScoreSheetsExportGet = (params) =>
|
||||
http.get('/workbench/score-sheets/export', { params, responseType: 'blob' });
|
||||
|
||||
export const orderBookTemplateGet = () =>
|
||||
http.get('/order-book/template', { responseType: 'blob' });
|
||||
|
||||
export const orderBookImportPost = (eventId, file) => {
|
||||
const data = new FormData();
|
||||
data.append('eventId', String(eventId));
|
||||
data.append('importMode', 'overwrite');
|
||||
data.append('file', file);
|
||||
return http.post('/order-book/import', data, { headers: { 'Content-Type': 'multipart/form-data' } });
|
||||
};
|
||||
|
||||
export const orderBookConfirmPost = (batchId) =>
|
||||
http.post('/order-book/confirm', { batchId, generateJudgeAccount: false });
|
||||
@ -101,7 +101,10 @@ export async function initBackEndControlRoutes() {
|
||||
},
|
||||
},
|
||||
];
|
||||
dynamicRoutes[0].children = await backEndComponent([...routeList, ...staticRoute]);
|
||||
// 工作台业务页需要携带赛事上下文,不能依赖菜单接口是否已经下发全部子菜单。
|
||||
// 这些路由只作为隐藏的运行路由注册,左侧菜单仍以后端权限菜单为准。
|
||||
const routesWithScoringFallback = ensureScoringOperationalRoutes(routeList);
|
||||
dynamicRoutes[0].children = await backEndComponent([...routesWithScoringFallback, ...staticRoute]);
|
||||
// dynamicRoutes[0].children = await backEndComponent(routeList);
|
||||
// 添加动态路由
|
||||
await setAddRoute();
|
||||
@ -163,6 +166,25 @@ export function getBackEndControlRoutes() {
|
||||
return SysMenuApi.getMenus();
|
||||
}
|
||||
|
||||
function ensureScoringOperationalRoutes(routes: any[]) {
|
||||
const paths = new Set<string>();
|
||||
const collectPaths = (items: any[]) => items.forEach((item) => {
|
||||
if (item?.path) paths.add(item.path);
|
||||
if (item?.children?.length) collectPaths(item.children);
|
||||
});
|
||||
collectPaths(routes);
|
||||
const scoringEnabled = paths.has('/scoring') || paths.has('/scoring/launcher') || paths.has('/scoring/workbench') || paths.has('/scoring/orderbook');
|
||||
if (!scoringEnabled) return routes;
|
||||
|
||||
const fallbacks = [
|
||||
{ path: '/scoring/launcher', name: 'scoringLauncherFallback', component: 'scoring/launcher/index', meta: { title: '工作台', isKeepAlive: true } },
|
||||
{ path: '/scoring/orderbook', name: 'scoringOrderBookFallback', component: 'scoring/orderbook/index', meta: { title: '秩序册', isHide: true, isKeepAlive: true } },
|
||||
{ path: '/scoring/workbench', name: 'scoringWorkbenchFallback', component: 'scoring/workbench/index', meta: { title: '成绩管理', isHide: true, isKeepAlive: true } },
|
||||
{ path: '/scoring/cast', name: 'scoringCastFallback', component: 'scoring/workbench/index', meta: { title: '成绩投屏', isHide: true, isKeepAlive: true } },
|
||||
].filter((item) => !paths.has(item.path));
|
||||
return [...routes, ...fallbacks];
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新请求后端路由菜单接口
|
||||
* @description 用于菜单管理界面刷新菜单(未进行测试)
|
||||
|
||||
@ -96,7 +96,7 @@ router.beforeEach(async (to, from, next) => {
|
||||
NProgress.configure({ showSpinner: false });
|
||||
if (to.meta.title) NProgress.start();
|
||||
const token = Session.get(accessTokenKey);
|
||||
if (to.path === '/login' && !token) {
|
||||
if ((to.path === '/login' || to.meta.isPublic) && !token) {
|
||||
next();
|
||||
NProgress.done();
|
||||
} else {
|
||||
|
||||
@ -27,6 +27,7 @@ declare module 'vue-router' {
|
||||
isIframe?: boolean;
|
||||
roles?: string[];
|
||||
icon?: string;
|
||||
isPublic?: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
@ -601,6 +602,24 @@ export const staticRoutes: Array<RouteRecordRaw> = [
|
||||
title: '登录',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/scoring/screen',
|
||||
name: 'scoringScreen',
|
||||
component: () => import('/@/views/scoring/screen/index.vue'),
|
||||
meta: {
|
||||
title: '成绩大屏',
|
||||
isPublic: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/scoring/checkin-screen',
|
||||
name: 'scoringCheckinScreen',
|
||||
component: () => import('/@/views/scoring/checkin-screen/index.vue'),
|
||||
meta: {
|
||||
title: '检录进度大屏',
|
||||
isPublic: true,
|
||||
},
|
||||
},
|
||||
/**
|
||||
* 提示:写在这里的为全屏界面,不建议写在这里
|
||||
* 请写在 `dynamicRoutes` 路由数组中
|
||||
|
||||
@ -48,6 +48,9 @@
|
||||
<el-button v-auth="'event:workbench'" size="small" text type="primary"
|
||||
@click="onOpenWorkBench(scope.row.id)"> 赛事工作台
|
||||
</el-button>
|
||||
<el-button v-auth="'event:workbench'" size="small" text type="success"
|
||||
@click="onOpenScoringWorkbench(scope.row)"> 打分工作台
|
||||
</el-button>
|
||||
</template>
|
||||
</ProTable>
|
||||
<MenuDialog ref="menuDialogRef" @refresh="tableRef?.reset" @update:table="refreshTable" />
|
||||
@ -58,6 +61,7 @@
|
||||
|
||||
<script setup name="event">
|
||||
import { computed, ref, reactive } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { auths } from '/@/utils/authFunction';
|
||||
import ProTable from '/@/components/ProTable/index.vue';
|
||||
@ -69,6 +73,7 @@ import { formatDate } from '/@/utils/formatTime';
|
||||
|
||||
//table组件实例
|
||||
const tableRef = ref();
|
||||
const router = useRouter();
|
||||
const exportDialogRef = ref();
|
||||
const initParam = ref({
|
||||
refresh: true,
|
||||
@ -331,6 +336,14 @@ const onOpenWorkBench = async (id) => {
|
||||
await workDialogRef.value.openDialog(id);
|
||||
};
|
||||
|
||||
const onOpenScoringWorkbench = (event) => {
|
||||
const href = router.resolve({
|
||||
path: '/scoring/launcher',
|
||||
query: { eventId: event.id, eventName: event.eventName },
|
||||
}).href;
|
||||
window.open(href, '_blank');
|
||||
};
|
||||
|
||||
//删除菜单
|
||||
const onDeleteMenu = async (id) => {
|
||||
const { succeeded, errors } = await eventDeleteDelete({ id });
|
||||
|
||||
52
src/views/permission/account/scoring-dialog.vue
Normal file
52
src/views/permission/account/scoring-dialog.vue
Normal file
@ -0,0 +1,52 @@
|
||||
<template>
|
||||
<el-dialog v-model="state.visible" :title="state.title" width="600px" destroy-on-close @closed="onClosed">
|
||||
<el-form ref="formRef" v-loading="state.loading" :model="state.form" :rules="rules" label-width="100px">
|
||||
<el-form-item label="单位名称:" prop="unitName"><el-input v-model.trim="state.form.unitName" maxlength="128" placeholder="请输入单位名称" clearable /></el-form-item>
|
||||
<el-form-item label="联系人:" prop="contactName"><el-input v-model.trim="state.form.contactName" maxlength="16" placeholder="请输入联系人" clearable /></el-form-item>
|
||||
<el-form-item label="账号:" prop="account"><el-input v-model.trim="state.form.account" maxlength="32" placeholder="请输入账号" clearable /></el-form-item>
|
||||
<el-form-item v-if="!state.form.id" label="密码:" prop="password"><el-input v-model="state.form.password" maxlength="32" placeholder="请输入密码" type="password" show-password clearable /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer><el-button @click="state.visible = false">取消</el-button><el-button type="primary" :loading="state.submitting" @click="submit">确定</el-button></template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup name="unitAccountDialog">
|
||||
import { nextTick, reactive, ref } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { unitaccountAddPost, unitaccountDetailGet, unitaccountEditPut } from '/@/api/match/unitaccount';
|
||||
|
||||
const emit = defineEmits(['refresh']);
|
||||
const formRef = ref();
|
||||
const emptyForm = () => ({ id: 0, platform: 1, unitName: '', contactName: '', account: '', password: '' });
|
||||
const state = reactive({ visible: false, title: '', loading: false, submitting: false, form: emptyForm() });
|
||||
const rules = {
|
||||
unitName: [{ required: true, message: '请输入单位名称', trigger: 'blur' }],
|
||||
contactName: [{ required: true, message: '请输入联系人', trigger: 'blur' }],
|
||||
account: [{ required: true, message: '请输入账号', trigger: 'blur' }],
|
||||
password: [{ required: true, message: '请输入密码', trigger: 'blur' }, { min: 6, max: 32, message: '密码长度为 6-32 位', trigger: 'blur' }],
|
||||
};
|
||||
const open = async (row, platform = 1) => {
|
||||
state.form = { ...emptyForm(), platform };
|
||||
state.title = row ? `编辑${platform === 1 ? '报名平台' : '打分系统'}账号` : `新增${platform === 1 ? '报名平台' : '打分系统'}账号`;
|
||||
state.visible = true;
|
||||
await nextTick();
|
||||
formRef.value?.clearValidate();
|
||||
if (!row) return;
|
||||
state.loading = true;
|
||||
try {
|
||||
const { succeeded, data } = await unitaccountDetailGet({ id: row.id });
|
||||
if (succeeded) state.form = { id: data.id, platform, unitName: data.unitName ?? '', contactName: data.contactName ?? '', account: data.account ?? '', password: '' };
|
||||
} finally { state.loading = false; }
|
||||
};
|
||||
const submit = async () => {
|
||||
if (!(await formRef.value?.validate().catch(() => false))) return;
|
||||
state.submitting = true;
|
||||
try {
|
||||
const payload = { unitName: state.form.unitName, contactName: state.form.contactName, account: state.form.account };
|
||||
const result = state.form.id ? await unitaccountEditPut({ id: state.form.id, ...payload }) : await unitaccountAddPost({ platform: state.form.platform, password: state.form.password, ...payload });
|
||||
if (result.succeeded) { ElMessage.success(state.form.id ? '编辑成功' : '新增成功'); state.visible = false; emit('refresh'); }
|
||||
} finally { state.submitting = false; }
|
||||
};
|
||||
const onClosed = () => { formRef.value?.resetFields(); state.form = emptyForm(); };
|
||||
defineExpose({ open });
|
||||
</script>
|
||||
70
src/views/scoring/checkin-screen/index.vue
Normal file
70
src/views/scoring/checkin-screen/index.vue
Normal file
File diff suppressed because one or more lines are too long
46
src/views/scoring/launcher/index.vue
Normal file
46
src/views/scoring/launcher/index.vue
Normal file
@ -0,0 +1,46 @@
|
||||
<template>
|
||||
<div class="launcher-page">
|
||||
<div class="brand"><span class="brand-mark">Y</span><div><strong>跃动赛事</strong><small>打分系统工作台</small></div></div>
|
||||
<main class="launcher-shell">
|
||||
<header><p>SCORING CONSOLE</p><h1>{{ step === 'mode' ? '请选择计分模式' : '请选择赛事' }}</h1><span>{{ step === 'mode' ? '进入系统前,请先选择本次比赛的计分模式' : '选择数据来源和赛事后,各业务菜单将使用同一赛事上下文' }}</span></header>
|
||||
<div v-if="step === 'mode'" class="mode-grid">
|
||||
<button class="mode-card active" type="button" @click="selectFreestyle"><span class="mode-icon">✦</span><b>花样打分</b><em>Freestyle scoring</em><i>选择赛事 →</i></button>
|
||||
<button class="mode-card disabled" type="button" disabled><span class="mode-icon">⌁</span><b>速度打分</b><em>Speed scoring</em><i>暂未开放</i></button>
|
||||
</div>
|
||||
<section v-else class="event-card">
|
||||
<div class="source-tabs"><button :class="{ active: sourceMode === 'registration' }" @click="switchSource('registration')">报名平台赛事</button><button :class="{ active: sourceMode === 'orderbook' }" @click="switchSource('orderbook')">秩序册导入</button></div>
|
||||
<div class="event-filter"><el-input v-model="keyword" clearable placeholder="搜索赛事名称" @keyup.enter="searchEvents"/><el-button type="primary" @click="searchEvents">查询</el-button><el-button @click="step = 'mode'">返回模式选择</el-button></div>
|
||||
<div v-loading="loading" class="event-list">
|
||||
<button v-for="event in events" :key="event.id" type="button" @click="enterEvent(event)"><div><strong>{{ event.eventName }}</strong><span>{{ dateRange(event) }}</span></div><el-tag size="small" :type="eventStatusType(event.eventStatus)">{{ eventStatusName(event.eventStatus) }}</el-tag><i>进入 →</i></button>
|
||||
<el-empty v-if="!loading && !events.length" :description="sourceMode === 'registration' ? '暂无可进入的赛事' : '暂无赛事,请先在赛事管理中创建赛事'"/>
|
||||
</div>
|
||||
<el-pagination v-if="total > pageSize" v-model:current-page="pageNo" :page-size="pageSize" :total="total" layout="prev, pager, next" @current-change="loadEvents"/>
|
||||
</section>
|
||||
</main>
|
||||
<div class="orb orb-a"></div><div class="orb orb-b"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { workbenchEventsGet } from '/@/api/match/workbench';
|
||||
|
||||
const route=useRoute(), router=useRouter(), step=ref('mode'), sourceMode=ref('registration'), keyword=ref(''), events=ref([]), loading=ref(false), pageNo=ref(1), total=ref(0);
|
||||
const pageSize=8;
|
||||
const requestedSourceMode=String(route.query.sourceMode||'');
|
||||
if(requestedSourceMode==='orderbook'||requestedSourceMode==='registration')sourceMode.value=requestedSourceMode;
|
||||
function selectFreestyle(){sessionStorage.setItem('scoring-mode','freestyle');step.value='event';loadEvents()}
|
||||
if(route.query.sourceMode||route.query.eventId){sessionStorage.setItem('scoring-mode','freestyle');step.value='event';loadEvents()}
|
||||
function switchSource(value){sourceMode.value=value;pageNo.value=1;loadEvents()}
|
||||
function searchEvents(){pageNo.value=1;loadEvents()}
|
||||
async function loadEvents(){loading.value=true;try{const res=await workbenchEventsGet({pageNo:pageNo.value,pageSize,eventName:keyword.value||undefined,sourceMode:sourceMode.value});const page=res?.data||{};events.value=page.rows||page.list||page.items||[];total.value=Number(page.total||page.totalCount||events.value.length)}finally{loading.value=false}}
|
||||
function enterEvent(event){const context={eventId:Number(event.id),eventName:event.eventName,mode:'freestyle',sourceMode:sourceMode.value};sessionStorage.setItem('scoring-context',JSON.stringify(context));const path=sourceMode.value==='orderbook'?'/scoring/orderbook':'/scoring/workbench';router.push({path,query:{eventId:context.eventId,eventName:context.eventName}})}
|
||||
function dateRange(event){const start=String(event.matchStartTime||'').slice(0,10),end=String(event.matchEndTime||'').slice(0,10);return start||end?`${start||'待定'} 至 ${end||'待定'}`:'比赛时间待定'}
|
||||
function eventStatusName(status){return({0:'未开始',1:'报名中',2:'报名结束',3:'比赛中',4:'已结束'})[status]||'待处理'}
|
||||
function eventStatusType(status){return({1:'warning',2:'info',3:'success',4:'info'})[status]||''}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.launcher-page{min-height:calc(100vh - 115px);overflow:hidden;position:relative;color:#073570;background:linear-gradient(135deg,#e8f7ff 0%,#f8fbff 50%,#e2efff 100%);font-family:"Microsoft YaHei",sans-serif}.launcher-page::before{content:"";position:absolute;inset:0;opacity:.35;background-image:linear-gradient(rgba(31,134,226,.08) 1px,transparent 1px),linear-gradient(90deg,rgba(31,134,226,.08) 1px,transparent 1px);background-size:40px 40px}.brand{position:relative;z-index:1;display:flex;align-items:center;gap:12px;padding:28px 4vw;color:#087cf0}.brand-mark{display:grid;place-items:center;width:44px;height:44px;color:#fff;font-size:24px;font-weight:800;border-radius:14px;background:linear-gradient(135deg,#006ff1,#22c7f1);box-shadow:0 10px 24px rgba(0,111,241,.25)}.brand strong,.brand small{display:block}.brand strong{font-size:20px}.brand small{font-size:11px;letter-spacing:3px;margin-top:3px}.launcher-shell{position:relative;z-index:1;width:min(920px,90vw);margin:5vh auto 50px}header{text-align:center;margin-bottom:34px}header p{margin:0;color:#169ce5;font-weight:700;letter-spacing:5px}header h1{font-size:42px;margin:10px 0;color:#052e6d}header span{color:#6b84a5}.mode-grid{display:grid;grid-template-columns:1fr 1fr;gap:26px}.mode-card{position:relative;min-height:230px;padding:35px;border:1px solid #b9dbfa;border-radius:28px;text-align:left;color:#fff;background:linear-gradient(135deg,#0478f4,#12cbd9);box-shadow:0 22px 50px rgba(12,111,214,.22);cursor:pointer;transition:.25s}.mode-card:hover{transform:translateY(-5px)}.mode-card.disabled{color:#8399b5;background:rgba(255,255,255,.7);box-shadow:none;cursor:not-allowed}.mode-card.disabled:hover{transform:none}.mode-icon{display:grid;place-items:center;width:58px;height:58px;margin-bottom:28px;border-radius:18px;font-size:32px;background:rgba(255,255,255,.2)}.mode-card b,.mode-card em,.mode-card i{display:block}.mode-card b{font-size:30px}.mode-card em{margin-top:7px;font-style:normal;opacity:.65}.mode-card i{position:absolute;right:30px;bottom:28px;font-style:normal;font-weight:700}.event-card{padding:24px;border:1px solid #d5e5f4;border-radius:20px;background:rgba(255,255,255,.9);box-shadow:0 18px 45px rgba(24,94,157,.12)}.source-tabs{display:flex;gap:8px;margin-bottom:18px}.source-tabs button{padding:10px 22px;border:0;border-radius:9px;color:#66809c;background:#edf4fa;cursor:pointer}.source-tabs button.active{color:#fff;background:#168be8}.event-filter{display:flex;gap:10px}.event-filter .el-input{width:320px}.event-list{min-height:300px;margin-top:18px}.event-list>button{display:flex;align-items:center;gap:15px;width:100%;margin-bottom:10px;padding:16px 18px;border:1px solid #e1eaf3;border-radius:11px;color:#29435f;background:#fff;text-align:left;cursor:pointer}.event-list>button:hover{border-color:#1999f5;box-shadow:0 6px 16px rgba(28,136,221,.1)}.event-list>button div{min-width:0;flex:1}.event-list strong,.event-list span{display:block}.event-list span{margin-top:7px;color:#8fa0b2;font-size:12px}.event-list i{color:#1589df;font-style:normal}.el-pagination{justify-content:center;margin-top:18px}.orb{position:absolute;border-radius:50%;filter:blur(2px)}.orb-a{width:360px;height:360px;right:-160px;top:5%;background:rgba(0,194,255,.13)}.orb-b{width:280px;height:280px;left:-130px;bottom:-80px;background:rgba(35,102,232,.12)}@media(max-width:760px){.launcher-shell{width:94vw;margin-top:2vh}header h1{font-size:32px}.mode-grid{grid-template-columns:1fr}.mode-card{min-height:185px}.event-filter{flex-wrap:wrap}.event-filter .el-input{width:100%}}
|
||||
</style>
|
||||
38
src/views/scoring/orderbook/index.vue
Normal file
38
src/views/scoring/orderbook/index.vue
Normal file
@ -0,0 +1,38 @@
|
||||
<template>
|
||||
<div class="orderbook-page">
|
||||
<header><div><h2>秩序册</h2><p>{{ eventName }} · 导入出场顺序和裁判名单</p></div><el-button @click="changeEvent">切换赛事</el-button></header>
|
||||
<el-alert v-if="!eventId" title="请先在打分系统工作台选择计分模式和赛事" type="warning" show-icon :closable="false"/>
|
||||
<section v-else class="import-card">
|
||||
<div class="steps"><div class="active"><b>1</b><span>下载模板</span></div><i></i><div :class="{active:file}"><b>2</b><span>上传解析</span></div><i></i><div :class="{active:result?.status===10}"><b>3</b><span>确认导入</span></div></div>
|
||||
<div class="actions"><el-button type="primary" plain @click="downloadTemplate">下载秩序册模板</el-button><el-upload :auto-upload="false" :show-file-list="false" accept=".xls,.xlsx" :on-change="selectFile"><el-button type="primary">选择 Excel 文件</el-button></el-upload><span v-if="file">{{ file.name }}</span><el-button type="success" :loading="parsing" :disabled="!file" @click="parseFile">上传并解析</el-button></div>
|
||||
<template v-if="result">
|
||||
<div class="summary"><div><small>总行数</small><strong>{{result.totalRows}}</strong></div><div><small>有效行</small><strong>{{result.validRows}}</strong></div><div><small>场次</small><strong>{{result.preview?.sessionCount||0}}</strong></div><div><small>出场记录</small><strong>{{result.preview?.entryCount||0}}</strong></div><div><small>裁判</small><strong>{{result.preview?.refereeCount||0}}</strong></div><div class="error"><small>错误 / 警告</small><strong>{{result.errorCount}} / {{result.warningCount}}</strong></div></div>
|
||||
<el-table v-if="result.errors?.length" :data="result.errors" max-height="330"><el-table-column prop="row" label="行号" width="80"/><el-table-column prop="column" label="列名" width="150"/><el-table-column prop="message" label="问题说明" min-width="300"/><el-table-column label="级别" width="100"><template #default="{row}"><el-tag :type="row.level==='error'?'danger':'warning'">{{row.level==='error'?'错误':'警告'}}</el-tag></template></el-table-column></el-table>
|
||||
<div class="confirm"><span v-if="result.errorCount">请修正错误后重新上传,存在错误时不能确认导入。</span><span v-else>解析通过,确认后将生成场地、场次、出场记录及裁判数据。</span><el-button type="primary" :loading="confirming" :disabled="result.status!==10||result.errorCount>0" @click="confirmImport">确认导入</el-button></div>
|
||||
</template>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import { orderBookTemplateGet, orderBookImportPost, orderBookConfirmPost } from '/@/api/match/workbench';
|
||||
|
||||
const route=useRoute(),router=useRouter();
|
||||
let saved={};try{saved=JSON.parse(sessionStorage.getItem('scoring-context')||'{}')}catch{saved={}}
|
||||
const eventId=Number(route.query.eventId)||Number(saved.eventId)||0,eventName=String(route.query.eventName||saved.eventName||'未选择赛事');
|
||||
const file=ref(null),result=ref(null),parsing=ref(false),confirming=ref(false);
|
||||
if(!eventId)router.replace({path:'/scoring/launcher',query:{sourceMode:'orderbook'}});
|
||||
function changeEvent(){router.push({path:'/scoring/launcher',query:{sourceMode:'orderbook'}})}
|
||||
function selectFile(uploadFile){file.value=uploadFile.raw;result.value=null}
|
||||
function saveBlob(blob,name){const url=URL.createObjectURL(blob);const a=document.createElement('a');a.href=url;a.download=name;document.body.appendChild(a);a.click();a.remove();URL.revokeObjectURL(url)}
|
||||
async function downloadTemplate(){saveBlob(await orderBookTemplateGet(),'秩序册导入模板.xlsx')}
|
||||
async function parseFile(){parsing.value=true;try{const res=await orderBookImportPost(eventId,file.value);result.value=res?.data||null;if(result.value)ElMessage.success(result.value.errorCount?'解析完成,请处理错误项':'解析通过,请确认导入')}finally{parsing.value=false}}
|
||||
async function confirmImport(){await ElMessageBox.confirm('确认导入当前秩序册?同一赛事原有编排数据将按导入规则处理。','确认导入',{type:'warning'});confirming.value=true;try{const res=await orderBookConfirmPost(result.value.batchId);ElMessage.success(`导入完成:${res?.data?.entryCount||0} 条出场记录`);result.value=null;file.value=null}finally{confirming.value=false}}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.orderbook-page{min-height:calc(100vh - 115px);padding:20px;background:#f4f7fb}.orderbook-page>header{display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;padding:20px 24px;border-radius:12px;background:#fff}.orderbook-page h2{margin:0;color:#172b4d}.orderbook-page p{margin:7px 0 0;color:#8b9bad}.import-card{padding:26px;border-radius:12px;background:#fff}.steps{display:flex;align-items:center;justify-content:center;margin:8px 0 32px}.steps>div{display:flex;align-items:center;gap:9px;color:#9aabba}.steps b{display:grid;place-items:center;width:30px;height:30px;border-radius:50%;color:#7890a7;background:#edf2f7}.steps .active{color:#168be8}.steps .active b{color:#fff;background:#168be8}.steps i{width:100px;height:1px;margin:0 18px;background:#dbe5ee}.actions{display:flex;align-items:center;gap:12px;padding:22px;border:1px dashed #b9cfdf;border-radius:10px;background:#f8fbfe}.actions>span{max-width:260px;overflow:hidden;color:#617991;text-overflow:ellipsis;white-space:nowrap}.summary{display:grid;grid-template-columns:repeat(6,1fr);gap:12px;margin:22px 0}.summary>div{padding:16px;border-radius:9px;background:#f2f7fc}.summary small,.summary strong{display:block}.summary small{color:#8194a7}.summary strong{margin-top:7px;color:#187dc4;font-size:24px}.summary .error strong{color:#e36b45}.confirm{display:flex;align-items:center;justify-content:flex-end;gap:20px;margin-top:20px;color:#7c8d9e}@media(max-width:900px){.summary{grid-template-columns:repeat(3,1fr)}.actions{flex-wrap:wrap}.steps i{width:35px}}
|
||||
</style>
|
||||
74
src/views/scoring/screen/index.vue
Normal file
74
src/views/scoring/screen/index.vue
Normal file
File diff suppressed because one or more lines are too long
179
src/views/scoring/workbench/index.vue
Normal file
179
src/views/scoring/workbench/index.vue
Normal file
@ -0,0 +1,179 @@
|
||||
<template>
|
||||
<div class="workbench-page">
|
||||
<section class="workbench-main">
|
||||
<header class="topbar">
|
||||
<div><h1>{{ tabTitle }}</h1><p>{{ eventName }} · {{ tabDescription }}</p></div>
|
||||
<div class="top-actions"><span :class="['live-dot', { offline }]">● {{ offline ? '连接异常' : '实时同步' }}</span><el-button v-if="activeTab !== 'screen'" :type="activeTab==='corrections'?'primary':'default'" @click="toggleCorrections">{{activeTab==='corrections'?'返回成绩列表':'修正申请'}}</el-button><el-button @click="openCheckinScreen">检录大屏</el-button><el-button @click="goLauncher">切换赛事</el-button><el-button @click="toggleFullscreen">全屏</el-button></div>
|
||||
</header>
|
||||
|
||||
<template v-if="activeTab !== 'corrections'">
|
||||
<div class="workspace">
|
||||
<aside class="session-panel">
|
||||
<div class="session-head"><b>比赛场次</b><el-button text type="primary" :loading="loadingSessions" @click="loadSessions">刷新</el-button></div>
|
||||
<el-input v-model="sessionKeyword" clearable placeholder="搜索场次" @keyup.enter="loadSessions" />
|
||||
<el-select v-model="sessionStatus" clearable placeholder="场次状态" style="width:100%;margin-top:8px" @change="loadSessions"><el-option v-for="x in sessionStatuses" :key="x.value" :label="x.label" :value="x.value" /></el-select>
|
||||
<div class="session-list">
|
||||
<button v-for="session in sessions" :key="session.sessionId" :class="{ active: selectedSession?.sessionId === session.sessionId }" @click="selectSession(session)">
|
||||
<div><strong>{{ session.projectName || '未命名项目' }}</strong><span>EP {{ session.sessionNo }} · {{ session.groupName || '混合组别' }} / {{ session.venueName }}</span></div>
|
||||
<em :class="`status-${session.sessionStatus}`">{{ session.sessionStatusName }}</em>
|
||||
<i><span :style="{ width: progress(session) + '%' }"></span></i><small>{{ session.completedEntries }}/{{ session.totalEntries }}</small>
|
||||
</button>
|
||||
<el-empty v-if="!loadingSessions && !sessions.length" :image-size="70" description="暂无场次" />
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="result-panel">
|
||||
<div class="filterbar">
|
||||
<el-input v-model="entryKeyword" clearable placeholder="姓名、队伍或出场编号" @keyup.enter="loadEntries" />
|
||||
<el-select v-model="resultStatus" clearable placeholder="成绩状态" @change="loadEntries"><el-option v-for="x in resultStatuses" :key="x.value" :label="x.label" :value="x.value" /></el-select>
|
||||
<el-button type="primary" @click="loadEntries">查询</el-button>
|
||||
<el-button @click="resetFilters">重置</el-button>
|
||||
<span class="filter-spacer"></span>
|
||||
<el-button v-if="activeTab === 'screen' || activeTab === 'results'" type="success" :disabled="!rankingOptions.length" @click="openRanking">投出排名</el-button>
|
||||
<el-button v-if="activeTab === 'screen'" @click="openScoreScreen">打开成绩大屏</el-button>
|
||||
<template v-if="activeTab === 'results'">
|
||||
<el-button type="primary" :loading="actionLoading" :disabled="!selectedResultIds.length" @click="batchConfirm">批量确认</el-button>
|
||||
<el-button @click="exportResults">成绩导出</el-button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-if="activeTab === 'screen' && currentCast" class="cast-banner">
|
||||
<span>LIVE</span><div><b>当前正在投屏:{{ currentCast.castType === 1 ? '单人成绩' : '组别排名' }}</b><small>{{ formatTime(currentCast.castTime) }}</small></div><el-button text type="danger" @click="withdrawCast">撤回投屏</el-button>
|
||||
</div>
|
||||
|
||||
<el-table v-loading="loadingEntries" :data="entries" row-key="sessionEntryId" height="calc(100vh - 245px)" @selection-change="onSelection">
|
||||
<el-table-column v-if="activeTab === 'results'" type="selection" width="44" :selectable="canConfirm" />
|
||||
<el-table-column prop="playOrder" label="序号" width="64" align="center"><template #default="{ row }"><b class="order">{{ row.playOrder }}</b><i v-if="row.isRematch" class="rematch-tag">重赛</i></template></el-table-column>
|
||||
<el-table-column label="选手 / 队伍" min-width="190"><template #default="{ row }"><strong>{{ row.teamName || row.participantName }}</strong><small class="subtext">{{ row.entryNo }} · {{ row.groupName }}<template v-if="row.unitName"> · {{row.unitName}}</template></small></template></el-table-column>
|
||||
<el-table-column label="裁判提交" width="125" align="center"><template #default="{ row }"><span :class="['submit-progress', { done: allSubmitted(row) }]">{{ submittedCount(row) }}/{{ row.judgeScores?.length || 0 }}</span></template></el-table-column>
|
||||
<el-table-column v-if="showDifficulty" label="难度" width="88" align="center"><template #default="{ row }">{{ score(row.result?.difficultyScore) }}</template></el-table-column>
|
||||
<el-table-column label="表现" width="88" align="center"><template #default="{ row }">{{ score(row.result?.performanceScore) }}</template></el-table-column>
|
||||
<el-table-column label="规定" width="88" align="center"><template #default="{ row }">{{ score(row.result?.requiredScore) }}</template></el-table-column>
|
||||
<el-table-column label="扣分" width="88" align="center"><template #default="{ row }">{{ score(row.result?.deductionScore) }}</template></el-table-column>
|
||||
<el-table-column label="总分" width="110" align="center"><template #default="{ row }"><b class="total" :class="{ dq: row.result?.isDisqualified }">{{ row.result?.isDisqualified ? 'DQ' : score(row.result?.totalScore ?? row.result?.scoreText) }}</b></template></el-table-column>
|
||||
<el-table-column label="状态" width="100"><template #default="{ row }"><el-tag v-if="row.result" :type="resultTag(row.result.resultStatus)" effect="light">{{ row.result.resultStatusName }}</el-tag><span v-else class="muted">计算中</span></template></el-table-column>
|
||||
<el-table-column label="操作" :width="activeTab === 'screen' ? 150 : 330" fixed="right"><template #default="{ row }">
|
||||
<template v-if="activeTab === 'screen'"><el-tooltip :content="castDisabledReason(row)" :disabled="canCast(row)"><span><el-button text type="primary" :disabled="!canCast(row)" @click="castScore(row)">投出成绩</el-button></span></el-tooltip><el-button text @click="showDetail(row)">详情</el-button></template>
|
||||
<template v-else><el-button text type="primary" @click="showDetail(row)">详情</el-button><el-button v-if="canConfirm(row)" text type="success" @click="confirmOne(row)">确认</el-button><el-button text type="warning" :disabled="!row.judgeScores?.length" @click="openCorrection(row)">成绩修正</el-button><el-button text type="danger" @click="confirmRematch(row)">重赛</el-button><el-dropdown trigger="click"><el-button text>更多</el-button><template #dropdown><el-dropdown-menu><el-dropdown-item @click="openReason('void', row)">作废成绩</el-dropdown-item><el-dropdown-item @click="exportSheets(row)">导出评分单</el-dropdown-item></el-dropdown-menu></template></el-dropdown></template>
|
||||
</template></el-table-column>
|
||||
</el-table>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<section v-else class="correction-page">
|
||||
<div class="filterbar correction-filter"><el-input v-model="correctionKeyword" clearable placeholder="搜索修正原因" @keyup.enter="loadCorrections"/><el-select v-model="correctionStatus" clearable placeholder="申请状态" @change="loadCorrections"><el-option v-for="x in correctionStatuses" :key="x.value" :label="x.label" :value="x.value"/></el-select><el-button type="primary" @click="loadCorrections">查询</el-button></div>
|
||||
<el-table v-loading="loadingCorrections" :data="corrections" height="calc(100vh - 175px)"><el-table-column prop="sessionNo" label="场次" width="120"/><el-table-column prop="entryNo" label="出场编号" width="130"/><el-table-column prop="participantName" label="运动员" min-width="140"/><el-table-column prop="judgeRole" label="裁判席位" width="110"/><el-table-column prop="applyUserName" label="申请人" width="110"/><el-table-column prop="applyReason" label="修正原因" min-width="240" show-overflow-tooltip/><el-table-column prop="createdTime" label="申请时间" width="175"><template #default="{row}">{{formatTime(row.createdTime)}}</template></el-table-column><el-table-column label="状态" width="105"><template #default="{row}"><el-tag>{{row.correctionStatusName}}</el-tag></template></el-table-column><el-table-column label="操作" width="180" fixed="right"><template #default="{row}"><el-button text @click="showCorrectionDetail(row)">详情</el-button><el-button v-if="row.correctionStatus===0" text type="success" @click="approveCorrection(row,true)">批准</el-button><el-button v-if="row.correctionStatus===0" text type="danger" @click="approveCorrection(row,false)">驳回</el-button></template></el-table-column></el-table>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<el-dialog v-model="detailVisible" title="评分详情" width="92%" destroy-on-close><div v-if="detailRow" class="detail-summary"><div><small>选手/队伍</small><b>{{detailRow.teamName || detailRow.participantName}}</b></div><div><small>总分</small><b>{{score(detailRow.result?.totalScore)}}</b></div><div><small>成绩版本</small><b>V{{detailRow.result?.resultVersion || 0}}</b></div></div><el-table :data="detailRows" max-height="520"><el-table-column prop="judgeRole" label="席位" width="80" fixed/><el-table-column prop="judgeRoleName" label="裁判类型" min-width="110" fixed/><el-table-column prop="judgeName" label="裁判" width="100" fixed/><el-table-column v-for="col in detailColumns" :key="col.key" :label="col.label" min-width="105" align="center"><template #default="{row}">{{score(row.itemValues[col.key])}}</template></el-table-column><el-table-column prop="totalScore" label="合计" width="90"/><el-table-column prop="sheetStatusName" label="状态" width="95"/></el-table></el-dialog>
|
||||
|
||||
<el-dialog v-model="rankingVisible" title="投出组别排名" width="520px"><el-alert type="info" :closable="false" title="请选择需要投屏的项目和组别"/><el-select v-model="rankingKey" placeholder="选择项目 / 组别" style="width:100%;margin-top:16px"><el-option v-for="x in rankingOptions" :key="x.key" :label="`${x.projectName} / ${x.groupName}`" :value="x.key"/></el-select><template #footer><el-button @click="rankingVisible=false">取消</el-button><el-button type="primary" :loading="actionLoading" :disabled="!rankingKey" @click="castRanking">确定投屏</el-button></template></el-dialog>
|
||||
|
||||
<el-dialog v-model="reasonVisible" title="作废成绩" width="520px"><el-form label-position="top" class="reason-form"><el-form-item label="作废原因" required><el-input v-model="reasonText" type="textarea" :rows="4" maxlength="200" show-word-limit placeholder="请填写 2-200 个字符"/></el-form-item></el-form><template #footer><el-button @click="reasonVisible=false">取消</el-button><el-button type="danger" :loading="actionLoading" @click="submitReason">确认</el-button></template></el-dialog>
|
||||
|
||||
<el-dialog v-model="correctionVisible" title="发起成绩修正" width="560px"><el-form label-position="top"><el-form-item label="裁判类型" required><el-select v-model="correctionForm.judgeRoleType" placeholder="请选择裁判类型" style="width:100%" @change="onCorrectionRoleChange"><el-option v-for="group in correctionGroups" :key="group.judgeRoleType" :label="group.judgeRoleName" :value="group.judgeRoleType"/></el-select></el-form-item><el-form-item label="评分单" required><el-select v-model="correctionForm.scoreSheetId" :disabled="!correctionForm.judgeRoleType" placeholder="请选择已提交评分单" style="width:100%"><el-option v-for="x in correctionSheetOptions" :key="x.scoreSheetId" :disabled="!isCorrectionSheetAvailable(x)" :label="`${x.judgeRole}号位 · ${x.judgeName} · ${score(x.totalScore)}分 · ${x.sheetStatusName}${x.hasPendingCorrection?'(处理中)':''}`" :value="x.scoreSheetId"/></el-select></el-form-item><el-form-item label="修正原因" required><el-input v-model="correctionForm.reason" type="textarea" :rows="3" maxlength="200" show-word-limit placeholder="请填写 2-200 个字符"/></el-form-item></el-form><template #footer><el-button @click="correctionVisible=false">取消</el-button><el-button type="primary" :loading="actionLoading" @click="submitCorrection">发送修正任务</el-button></template></el-dialog>
|
||||
<el-dialog v-model="rematchVisible" title="这是进行一项操作时必须了解的重要信息" width="520px"><div class="dialog-desc">确认重赛,本组选手将顺延至当前组别最后出场进行重赛,你还要继续吗?</div><template #footer><el-button @click="rematchVisible=false">取消</el-button><el-button type="danger" :loading="actionLoading" @click="submitRematch">继续</el-button></template></el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import {
|
||||
workbenchSessionsGet, workbenchSessionEntriesGet, workbenchScreenCastPost,
|
||||
workbenchScreenCastCurrentGet, workbenchScreenCastWithdrawPost, workbenchCorrectionsGet,
|
||||
workbenchCorrectionDetailGet, workbenchCorrectionApprovePost, workbenchResultConfirmPost,
|
||||
workbenchResultsBatchConfirmPost, workbenchEntryRematchPost, workbenchResultVoidPost,
|
||||
judgeScoreCorrectionInitiatePost, workbenchResultsExportGet, workbenchScoreSheetsExportGet,
|
||||
} from '/@/api/match/workbench';
|
||||
|
||||
const route=useRoute(), router=useRouter();
|
||||
let savedContext={};try{savedContext=JSON.parse(sessionStorage.getItem('scoring-context')||'{}')}catch{savedContext={}}
|
||||
const eventId=ref(Number(route.query.eventId)||Number(savedContext.eventId)||0);
|
||||
const eventName=ref(String(route.query.eventName||savedContext.eventName||'未选择赛事'));
|
||||
if(eventId.value)sessionStorage.setItem('scoring-context',JSON.stringify({...savedContext,eventId:eventId.value,eventName:eventName.value,mode:'freestyle'}));
|
||||
const activeTab=ref('screen'), sessions=ref([]), selectedSession=ref(null), entries=ref([]), currentCast=ref(null);
|
||||
const loadingSessions=ref(false), loadingEntries=ref(false), offline=ref(false), sessionKeyword=ref(''), sessionStatus=ref(null), entryKeyword=ref(''), resultStatus=ref(null), selectedResultIds=ref([]);
|
||||
const detailVisible=ref(false), detailRow=ref(null), rankingVisible=ref(false), reasonVisible=ref(false), reasonAction=ref(''), reasonText=ref(''), reasonRow=ref(null), actionLoading=ref(false);
|
||||
const correctionVisible=ref(false), correctionRow=ref(null), correctionForm=ref({scoreSheetId:null,reason:''});
|
||||
const corrections=ref([]), loadingCorrections=ref(false), correctionKeyword=ref(''), correctionStatus=ref(null);
|
||||
let pollTimer;
|
||||
let entriesRequesting=false,currentCastRequesting=false;
|
||||
|
||||
const resultStatuses=[{value:0,label:'计算中'},{value:10,label:'待复核'},{value:20,label:'已确认'},{value:30,label:'已投屏'},{value:40,label:'已更正'},{value:90,label:'已作废'}];
|
||||
const sessionStatuses=[{value:0,label:'草稿'},{value:10,label:'就绪'},{value:20,label:'检录中'},{value:30,label:'进行中'},{value:40,label:'暂停'},{value:50,label:'待确认'},{value:60,label:'已结束'}];
|
||||
const rankingKey=ref('');
|
||||
const rankingOptions=computed(()=>{const map=new Map();for(const x of sessions.value){if(x.eventProjectId&&x.eventProjectGroupId){const key=`${x.eventProjectId}:${x.eventProjectGroupId}`;if(!map.has(key))map.set(key,{...x,key})}}return [...map.values()]});
|
||||
const rankingSelection=computed(()=>rankingOptions.value.find(x=>x.key===rankingKey.value));
|
||||
const showDifficulty=computed(()=>!String(selectedSession.value?.scoreRuleCode||'').toUpperCase().includes('PRESCRIBED'));
|
||||
const detailColumns=computed(()=>{const map=new Map();for(const sheet of detailRow.value?.judgeScores||[])for(const item of sheet.scoreItems||[]){const key=`${item.itemCode}:${item.subjectCode||''}`;if(!map.has(key))map.set(key,{key,label:item.subjectCode?`${item.itemName}-${item.subjectCode}`:item.itemName,sort:item.sort})}return [...map.values()].sort((a,b)=>a.sort-b.sort)});
|
||||
const detailRows=computed(()=>(detailRow.value?.judgeScores||[]).map(sheet=>({...sheet,itemValues:Object.fromEntries((sheet.scoreItems||[]).map(item=>[`${item.itemCode}:${item.subjectCode||''}`,item.inputValue??item.countValue??item.subtotal]))})));
|
||||
const correctionStatuses=[{value:0,label:'已申请'},{value:10,label:'已批准'},{value:20,label:'已拒绝'},{value:30,label:'裁判修改中'},{value:40,label:'已完成'}];
|
||||
const tabTitle=computed(()=>({screen:'成绩投屏',results:'成绩管理',corrections:'成绩修正申请'})[activeTab.value]);
|
||||
const tabDescription=computed(()=>({screen:'选择已完成成绩投到现场大屏',results:'复核成绩、查看评分单并处理异常比赛',corrections:'审批裁判发起的修正申请并追踪处理进度'})[activeTab.value]);
|
||||
|
||||
async function ensureEvent(){
|
||||
if(eventId.value)return true;
|
||||
ElMessage.warning('请先在打分系统工作台选择计分模式和赛事');
|
||||
router.replace('/scoring/launcher');
|
||||
return false;
|
||||
}
|
||||
|
||||
async function loadSessions(){if(!await ensureEvent())return;loadingSessions.value=true;try{const res=await workbenchSessionsGet({eventId:eventId.value,keyword:sessionKeyword.value||undefined,sessionStatus:sessionStatus.value??undefined});sessions.value=res?.data||[];if(!sessions.value.some(x=>x.sessionId===selectedSession.value?.sessionId))selectedSession.value=null;if(!selectedSession.value&&sessions.value.length)await selectSession(sessions.value[0]);}finally{loadingSessions.value=false}}
|
||||
async function selectSession(session){selectedSession.value=session;await loadEntries()}
|
||||
async function loadEntries(silent=false){if(!selectedSession.value||entriesRequesting)return;entriesRequesting=true;if(!silent)loadingEntries.value=true;try{const res=await workbenchSessionEntriesGet(selectedSession.value.sessionId,{keyword:entryKeyword.value||undefined,resultStatus:resultStatus.value??undefined});entries.value=res?.data||[];offline.value=false}catch{offline.value=true}finally{entriesRequesting=false;loadingEntries.value=false}}
|
||||
async function loadCurrentCast(){if(!eventId.value||currentCastRequesting)return;currentCastRequesting=true;try{const res=await workbenchScreenCastCurrentGet({eventId:eventId.value,venueId:selectedSession.value?.venueId});currentCast.value=res?.data||null;offline.value=false}catch{offline.value=true}finally{currentCastRequesting=false}}
|
||||
async function loadCorrections(){if(!eventId.value)return;loadingCorrections.value=true;try{const res=await workbenchCorrectionsGet({eventId:eventId.value,keyword:correctionKeyword.value||undefined,correctionStatus:correctionStatus.value??undefined});corrections.value=res?.data||[]}finally{loadingCorrections.value=false}}
|
||||
function resetFilters(){entryKeyword.value='';resultStatus.value=null;loadEntries()}
|
||||
function progress(x){return x.totalEntries?Math.round(x.completedEntries*100/x.totalEntries):0}
|
||||
function submittedCount(row){return (row.judgeScores||[]).filter(x=>x.sheetStatus>=10&&x.sheetStatus<90).length}
|
||||
function allSubmitted(row){return !!row.judgeScores?.length&&submittedCount(row)===row.judgeScores.length}
|
||||
function canCast(row){return !!row.result&&allSubmitted(row)&&row.result.resultStatus>=20&&row.result.resultStatus<90&&!row.result.isDisqualified}
|
||||
function castDisabledReason(row){if(!row.result)return '尚未生成成绩';if(!allSubmitted(row))return '裁判成绩尚未全部提交';if(row.result.isDisqualified)return 'DQ 成绩不可投屏';if(row.result.resultStatus<20)return '成绩尚未确认';return '该成绩不可投屏'}
|
||||
function canConfirm(row){return row.result?.resultStatus===10}
|
||||
function score(value){if(value===null||value===undefined||value==='')return '—';const n=Number(value);return Number.isFinite(n)?n.toFixed(2).replace(/\.00$/,'').replace(/(\.\d)0$/,'$1'):value}
|
||||
function resultTag(status){return ({10:'warning',20:'success',30:'primary',40:'success',90:'danger'})[status]||'info'}
|
||||
function formatTime(value){return value?String(value).replace('T',' ').slice(0,19):'—'}
|
||||
function onSelection(rows){selectedResultIds.value=rows.map(x=>x.result?.resultId).filter(Boolean)}
|
||||
function showDetail(row){detailRow.value=row;detailVisible.value=true}
|
||||
|
||||
async function castScore(row){if(actionLoading.value)return;actionLoading.value=true;try{await workbenchScreenCastPost({eventId:eventId.value,castType:1,venueId:selectedSession.value.venueId,sessionId:selectedSession.value.sessionId,sessionEntryId:row.sessionEntryId,eventResultId:row.result.resultId,resultVersion:row.result.resultVersion});ElMessage.success('成绩已投出');await Promise.all([loadEntries(true),loadCurrentCast()])}finally{actionLoading.value=false}}
|
||||
function openRanking(){rankingKey.value=selectedSession.value?.eventProjectGroupId?`${selectedSession.value.eventProjectId}:${selectedSession.value.eventProjectGroupId}`:'';rankingVisible.value=true}
|
||||
async function castRanking(){const target=rankingSelection.value;if(!target)return ElMessage.warning('请选择项目和组别');if(actionLoading.value)return;actionLoading.value=true;try{await workbenchScreenCastPost({eventId:eventId.value,castType:2,venueId:selectedSession.value?.venueId,eventProjectId:target.eventProjectId,eventProjectGroupId:target.eventProjectGroupId});rankingVisible.value=false;ElMessage.success('组别排名已投出');await Promise.all([loadEntries(true),loadCurrentCast()])}finally{actionLoading.value=false}}
|
||||
async function withdrawCast(){if(actionLoading.value)return;await ElMessageBox.confirm('撤回后大屏将回到等待状态,是否继续?','撤回投屏',{type:'warning'});actionLoading.value=true;try{await workbenchScreenCastWithdrawPost(currentCast.value.screenCastId);currentCast.value=null;ElMessage.success('已撤回')}finally{actionLoading.value=false}}
|
||||
async function confirmOne(row){if(actionLoading.value)return;actionLoading.value=true;try{await workbenchResultConfirmPost(row.result.resultId);ElMessage.success('成绩已确认');await loadEntries()}finally{actionLoading.value=false}}
|
||||
async function batchConfirm(){if(actionLoading.value)return;await ElMessageBox.confirm(`确认选中的 ${selectedResultIds.value.length} 条成绩?`,'批量确认');actionLoading.value=true;try{await workbenchResultsBatchConfirmPost({sessionId:selectedSession.value.sessionId,resultIds:selectedResultIds.value});ElMessage.success('批量确认完成');await loadEntries()}finally{actionLoading.value=false}}
|
||||
function openReason(action,row){reasonAction.value=action;reasonRow.value=row;reasonText.value='';reasonVisible.value=true}
|
||||
async function submitReason(){const text=reasonText.value.trim();if(text.length<2)return ElMessage.warning('请填写至少 2 个字符的原因');actionLoading.value=true;try{if(reasonAction.value==='rematch')await workbenchEntryRematchPost({originalSessionEntryId:reasonRow.value.sessionEntryId,rematchReason:text});else await workbenchResultVoidPost({sessionEntryId:reasonRow.value.sessionEntryId,voidReason:text});reasonVisible.value=false;ElMessage.success(reasonAction.value==='rematch'?'重赛已安排到场次末尾':'成绩已作废');loadEntries()}finally{actionLoading.value=false}}
|
||||
function openCorrection(row){correctionRow.value=row;correctionForm.value={scoreSheetId:null,reason:''};correctionVisible.value=true}
|
||||
async function submitCorrection(){const reason=correctionForm.value.reason.trim();if(!correctionForm.value.scoreSheetId)return ElMessage.warning('请选择裁判评分单');if(reason.length<2)return ElMessage.warning('请填写至少 2 个字符的修正原因');actionLoading.value=true;try{await judgeScoreCorrectionInitiatePost({scoreSheetId:correctionForm.value.scoreSheetId,reason});correctionVisible.value=false;ElMessage.success('修正任务已发送给裁判');loadEntries()}finally{actionLoading.value=false}}
|
||||
async function approveCorrection(row,isApproved){let remark='';if(!isApproved)remark=await ElMessageBox.prompt('请输入驳回原因','驳回修正申请',{inputValidator:v=>String(v||'').trim().length>=2||'至少输入2个字符'}).then(x=>x.value);else await ElMessageBox.confirm('批准后将向裁判下发修正任务,是否继续?','批准申请');await workbenchCorrectionApprovePost({correctionId:row.correctionId,isApproved,approveRemark:remark});ElMessage.success('申请已处理');loadCorrections()}
|
||||
async function showCorrectionDetail(row){const res=await workbenchCorrectionDetailGet(row.correctionId);const x=res?.data;if(!x)return;ElMessageBox.alert(`选手:${x.entry?.participantName||'-'}\n裁判:${x.originalScore?.judgeName||'-'}(${x.originalScore?.judgeRoleName||'-'})\n原分:${score(x.originalScore?.totalScore)}\n原因:${x.applyReason}`,'修正详情',{confirmButtonText:'关闭'})}
|
||||
|
||||
async function download(blob,name){const url=URL.createObjectURL(blob);const a=document.createElement('a');a.href=url;a.download=name;document.body.appendChild(a);a.click();a.remove();URL.revokeObjectURL(url)}
|
||||
async function exportResults(){const blob=await workbenchResultsExportGet({eventId:eventId.value,sessionId:selectedSession.value?.sessionId,keyword:entryKeyword.value||undefined,resultStatus:resultStatus.value??undefined});download(blob,`成绩册_${eventId.value}.xlsx`)}
|
||||
async function exportSheets(){const blob=await workbenchScoreSheetsExportGet({eventId:eventId.value,sessionId:selectedSession.value.sessionId});download(blob,`评分单_${selectedSession.value.sessionNo}.xlsx`)}
|
||||
function openScoreScreen(){window.open(router.resolve({path:'/scoring/screen',query:{eventId:eventId.value,venueId:selectedSession.value?.venueId,eventName:eventName.value,mode:'freestyle'}}).href,'_blank')}
|
||||
function openCheckinScreen(){window.open(router.resolve({path:'/scoring/checkin-screen',query:{eventId:eventId.value}}).href,'_blank')}
|
||||
function goLauncher(){router.push({path:'/scoring/launcher'})}
|
||||
function toggleCorrections(){activeTab.value=activeTab.value==='corrections'?'results':'corrections';if(activeTab.value==='corrections')loadCorrections()}
|
||||
function toggleFullscreen(){if(!document.fullscreenElement)document.documentElement.requestFullscreen?.();else document.exitFullscreen?.()}
|
||||
function startPolling(){pollTimer=window.setInterval(()=>{if(document.visibilityState==='visible'){loadCurrentCast();if(activeTab.value!=='corrections')loadEntries(true)}},5000)}
|
||||
watch(()=>selectedSession.value?.venueId,loadCurrentCast);
|
||||
watch(()=>route.path,(path)=>{activeTab.value=path==='/scoring/cast'?'screen':'results'});
|
||||
onMounted(async ()=>{
|
||||
activeTab.value=route.path==='/scoring/cast'?'screen':'results';
|
||||
await loadSessions();
|
||||
loadCurrentCast();
|
||||
startPolling();
|
||||
});
|
||||
onBeforeUnmount(()=>window.clearInterval(pollTimer));
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.workbench-page{height:calc(100vh - 115px);display:flex;overflow:hidden;color:#172b4d;background:#f2f6fb;font-family:"Microsoft YaHei",sans-serif}.workbench-main{min-width:0;flex:1;display:flex;flex-direction:column}.topbar{height:80px;flex:none;display:flex;align-items:center;justify-content:space-between;padding:0 28px;border-bottom:1px solid #e2eaf3;background:#fff}.topbar h1{margin:0;font-size:22px}.topbar p{margin:5px 0 0;color:#91a0b4;font-size:12px}.top-actions{display:flex;align-items:center;gap:10px}.live-dot{color:#22b573;font-size:12px}.live-dot.offline{color:#e74c3c}.workspace{min-height:0;flex:1;display:flex;padding:16px;gap:14px}.session-panel{width:278px;flex:none;display:flex;flex-direction:column;padding:16px;border:1px solid #e1eaf3;border-radius:12px;background:#fff}.session-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px}.session-list{min-height:0;overflow:auto;margin-top:12px}.session-list>button{position:relative;display:grid;grid-template-columns:1fr auto;gap:8px;width:100%;margin-bottom:9px;padding:13px;border:1px solid #e4ecf4;border-radius:10px;color:#29435f;background:#fff;text-align:left;cursor:pointer}.session-list>button:hover,.session-list>button.active{border-color:#1999f5;background:#f0f9ff;box-shadow:0 6px 16px rgba(28,136,221,.1)}.session-list strong,.session-list span{display:block}.session-list strong{font-size:13px}.session-list div span{margin-top:7px;color:#8fa0b2;font-size:11px}.session-list em{padding:3px 6px;height:max-content;border-radius:10px;color:#178bdf;background:#eaf6ff;font-size:10px;font-style:normal}.session-list i{height:4px;overflow:hidden;border-radius:4px;background:#e8eff5}.session-list i span{height:100%;border-radius:4px;background:linear-gradient(90deg,#0584f4,#20cfd6)}.session-list small{color:#7f91a4;text-align:right}.result-panel{min-width:0;flex:1;padding:15px 15px 0;border:1px solid #e1eaf3;border-radius:12px;background:#fff;overflow:hidden}.filterbar{display:flex;align-items:center;gap:9px;margin-bottom:13px}.filterbar>.el-input{width:220px}.filterbar>.el-select{width:130px}.filter-spacer{flex:1}.cast-banner{display:flex;align-items:center;gap:12px;margin-bottom:12px;padding:9px 12px;border:1px solid #bee8dc;border-radius:8px;background:#effbf7}.cast-banner>span{padding:4px 7px;border-radius:5px;color:#fff;background:#13ad72;font-size:10px;font-weight:700}.cast-banner div{flex:1}.cast-banner b,.cast-banner small{display:block}.cast-banner small{margin-top:3px;color:#799286;font-size:10px}.subtext{display:block;margin-top:5px;color:#93a0ae}.order{display:block;color:#198fe8}.rematch-tag{display:block;margin-top:3px;color:#e36b25;font-size:9px;font-style:normal}.submit-progress{padding:4px 8px;border-radius:13px;color:#d88417;background:#fff4dc}.submit-progress.done{color:#11985c;background:#e8f9f0}.total{color:#096fc8;font-size:18px}.total.dq{color:#e54c4c}.muted{color:#9daab6}.correction-page{margin:16px;padding:16px;border:1px solid #e1eaf3;border-radius:12px;background:#fff;overflow:hidden}.correction-filter>.el-input{width:280px}.detail-summary{display:grid;grid-template-columns:2fr 1fr 1fr;gap:12px;margin-bottom:18px}.detail-summary>div{padding:14px;border-radius:9px;background:#f2f7fc}.detail-summary small,.detail-summary b{display:block}.detail-summary small{color:#8ea0b1}.detail-summary b{margin-top:6px;font-size:18px}.dialog-desc{margin-top:16px}.reason-form{margin-top:16px}
|
||||
@media(max-width:1000px){.workbench-aside{width:190px}.session-panel{width:235px}.top-actions .live-dot{display:none}}
|
||||
</style>
|
||||
@ -1,211 +1,69 @@
|
||||
<template>
|
||||
<div class="system-unit-container layout-padding">
|
||||
<el-tabs v-model="activeTab" class="unit-tabs">
|
||||
<el-tab-pane label="报名平台" :name="UnitAccountPlatform.Registration" />
|
||||
<el-tab-pane label="打分系统" :name="UnitAccountPlatform.Scoring" />
|
||||
<div class="unit-account-container layout-padding">
|
||||
<el-tabs v-model="activePlatform" @tab-change="onPlatformChange">
|
||||
<el-tab-pane label="报名平台账号" name="registration" />
|
||||
<el-tab-pane label="打分系统账号" name="scoring" />
|
||||
</el-tabs>
|
||||
|
||||
<ProTable
|
||||
ref="tableRef"
|
||||
:key="activeTab"
|
||||
:request-api="unitaccountPageGet"
|
||||
:columns="columns"
|
||||
:init-param="initParam"
|
||||
:pagination="true"
|
||||
:tool-button="false"
|
||||
row-key="id"
|
||||
>
|
||||
<ProTable ref="tableRef" :key="tableKey" :request-api="unitaccountPageGet" :columns="columns" :init-param="{ Platform: platform }" :pagination="true" :tool-button="true" row-key="id">
|
||||
<template #tools>
|
||||
<el-button v-auth="'system:unit:add'" type="primary" icon="ele-Plus" @click="onOpenDialog()"> 新增 </el-button>
|
||||
<el-button v-if="can('add')" type="primary" icon="ele-Plus" @click="dialogRef?.open(undefined, platform)">新增</el-button>
|
||||
</template>
|
||||
<template #status="scope">
|
||||
<el-tag :type="scope.row.status === 0 ? 'success' : 'danger'">
|
||||
{{ scope.row.status === 0 ? '启用' : '禁用' }}
|
||||
</el-tag>
|
||||
<el-tag :type="isEnabled(scope.row.status) ? 'success' : 'danger'">{{ isEnabled(scope.row.status) ? '启用' : '禁用' }}</el-tag>
|
||||
</template>
|
||||
<template #action="scope">
|
||||
<el-button v-auth="'system:unit:status'" size="small" text :type="scope.row.status === 0 ? 'warning' : 'primary'" @click="onSetStatus(scope.row)">
|
||||
{{ scope.row.status === 0 ? '禁用' : '启用' }}
|
||||
</el-button>
|
||||
<el-button v-auth="'system:unit:edit'" icon="ele-Edit" size="small" text type="primary" @click="onOpenDialog(scope.row)"> 编辑 </el-button>
|
||||
<el-button v-auth="'system:unit:resetpwd'" icon="ele-Key" size="small" text type="primary" @click="onOpenResetPassword(scope.row)"> 重置密码 </el-button>
|
||||
<el-popconfirm title="确认删除吗?" @confirm="onDelete(scope.row)">
|
||||
<template #reference>
|
||||
<el-button v-auth="'system:unit:delete'" icon="ele-Delete" size="small" text type="danger"> 删除 </el-button>
|
||||
</template>
|
||||
</el-popconfirm>
|
||||
<el-button v-if="can('status')" size="small" text :type="isEnabled(scope.row.status) ? 'warning' : 'primary'" @click="setStatus(scope.row)">{{ isEnabled(scope.row.status) ? '禁用' : '启用' }}</el-button>
|
||||
<el-button v-if="can('edit')" icon="ele-Edit" size="small" text type="primary" @click="dialogRef?.open(scope.row, platform)">编辑</el-button>
|
||||
<el-button v-if="can('resetpwd')" icon="ele-Key" size="small" text type="primary" @click="openReset(scope.row)">重置密码</el-button>
|
||||
<el-popconfirm v-if="can('delete')" title="确认删除吗?" @confirm="deleteAccount(scope.row.id)"><template #reference><el-button icon="ele-Delete" size="small" text type="danger">删除</el-button></template></el-popconfirm>
|
||||
</template>
|
||||
</ProTable>
|
||||
|
||||
<!-- 新增/编辑弹窗 -->
|
||||
<UnitDialog ref="unitDialogRef" @refresh="refreshTable" />
|
||||
|
||||
<!-- 重置密码弹窗 -->
|
||||
<el-dialog title="重置密码" v-model="isShowResetDialog" width="500px" @close="onResetDialogClose">
|
||||
<el-form ref="resetFormRef" :model="resetForm" :rules="resetRules" label-width="100px" size="default">
|
||||
<el-form-item label="单位名称:">
|
||||
<el-input v-model="resetForm.unitName" disabled></el-input>
|
||||
</el-form-item>
|
||||
<el-form-item label="账号:">
|
||||
<el-input v-model="resetForm.account" disabled></el-input>
|
||||
</el-form-item>
|
||||
<el-form-item label="新密码:" prop="password">
|
||||
<el-input v-model="resetForm.password" placeholder="请输入新密码" type="password" maxlength="32" show-password clearable></el-input>
|
||||
</el-form-item>
|
||||
<UnitAccountDialog ref="dialogRef" @refresh="refresh" />
|
||||
<el-dialog v-model="resetDialog.visible" title="重置密码" width="500px" destroy-on-close @closed="resetFormRef?.resetFields()">
|
||||
<el-form ref="resetFormRef" :model="resetDialog" :rules="resetRules" label-width="100px">
|
||||
<el-form-item label="账号:"><el-input :model-value="resetDialog.account" disabled /></el-form-item>
|
||||
<el-form-item label="新密码:" prop="password"><el-input v-model="resetDialog.password" maxlength="32" type="password" placeholder="请输入新密码" show-password clearable /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<span class="dialog-footer">
|
||||
<el-button @click="isShowResetDialog = false">取消</el-button>
|
||||
<el-button type="primary" @click="onSubmitResetPassword" :loading="resetSubmitting">确定</el-button>
|
||||
</span>
|
||||
</template>
|
||||
<template #footer><el-button @click="resetDialog.visible = false">取消</el-button><el-button type="primary" :loading="resetDialog.submitting" @click="submitReset">确定</el-button></template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup name="systemunit">
|
||||
import { computed, defineAsyncComponent, reactive, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import ProTable from '/@/components/ProTable/index.vue';
|
||||
import {
|
||||
unitaccountPageGet,
|
||||
unitaccountDeleteDelete,
|
||||
unitaccountSetstatusPatch,
|
||||
unitaccountResetPasswordPatch,
|
||||
} from '/@/api/match/unitaccount';
|
||||
|
||||
const UnitDialog = defineAsyncComponent(() => import('/@/views/system/unit/dialog.vue'));
|
||||
|
||||
// 所属平台(与后端 UnitAccountPlatform 枚举一致)
|
||||
const UnitAccountPlatform = {
|
||||
Registration: 1, // 报名平台
|
||||
Scoring: 2, // 打分系统
|
||||
};
|
||||
|
||||
// 平台名称,用于禁用提示文案
|
||||
const platformNames = {
|
||||
[UnitAccountPlatform.Registration]: '报名平台',
|
||||
[UnitAccountPlatform.Scoring]: '打分系统',
|
||||
};
|
||||
import { auths } from '/@/utils/authFunction';
|
||||
import { unitaccountDeleteDelete, unitaccountPageGet, unitaccountResetPasswordPost, unitaccountSetstatusPost } from '/@/api/match/unitaccount';
|
||||
|
||||
const UnitAccountDialog = defineAsyncComponent(() => import('/@/views/permission/account/scoring-dialog.vue'));
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const activePlatform = ref(route.query.platform === 'scoring' ? 'scoring' : 'registration');
|
||||
const platform = computed(() => activePlatform.value === 'scoring' ? 2 : 1);
|
||||
const tableKey = ref(0);
|
||||
const tableRef = ref();
|
||||
const unitDialogRef = ref();
|
||||
const activeTab = ref(UnitAccountPlatform.Registration);
|
||||
|
||||
// Platform 为后端必填项;切换页签时 key 变化会重建表格,同时重置搜索条件与页码
|
||||
const initParam = computed(() => ({ Platform: activeTab.value }));
|
||||
|
||||
const columns = ref([
|
||||
{ prop: 'index', label: '序号', type: 'index', align: 'center', width: '80px' },
|
||||
const dialogRef = ref();
|
||||
const columns = [
|
||||
{ prop: 'index', label: '序号', type: 'index', align: 'center', width: 80 },
|
||||
{ prop: 'unitName', label: '单位名称', search: { el: 'input', key: 'UnitName' } },
|
||||
{ prop: 'contactName', label: '联系人' },
|
||||
{ prop: 'account', label: '账号', search: { el: 'input', key: 'Account' } },
|
||||
{ prop: 'status', label: '状态' },
|
||||
{
|
||||
prop: 'createdTime',
|
||||
label: '生成时间',
|
||||
search: {
|
||||
el: 'date-picker',
|
||||
props: { type: 'daterange', valueFormat: 'YYYY-MM-DD' },
|
||||
},
|
||||
},
|
||||
{ prop: 'action', label: '操作', align: 'center', fixed: 'right', width: '280px' },
|
||||
]);
|
||||
|
||||
const refreshTable = () => {
|
||||
tableRef.value?.getTableList();
|
||||
};
|
||||
|
||||
// 新增/编辑
|
||||
const onOpenDialog = (row) => {
|
||||
unitDialogRef.value.openDialog(activeTab.value, row);
|
||||
};
|
||||
|
||||
// 启用/禁用
|
||||
const onSetStatus = async (row) => {
|
||||
const isDisable = row.status === 0;
|
||||
const platformName = platformNames[row.platform ?? activeTab.value];
|
||||
|
||||
if (isDisable) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认禁用后用户将禁止登录${platformName}。你还要继续吗?`, '这是进行一项操作时必须了解的重要信息', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 失败提示由 request.ts 响应拦截器统一弹出,此处只处理成功分支
|
||||
const { succeeded } = await unitaccountSetstatusPatch({ id: row.id, status: isDisable ? 1 : 0 });
|
||||
if (succeeded) {
|
||||
ElMessage.success(isDisable ? '禁用成功' : '启用成功');
|
||||
refreshTable();
|
||||
}
|
||||
};
|
||||
|
||||
// 删除
|
||||
const onDelete = async (row) => {
|
||||
const { succeeded } = await unitaccountDeleteDelete({ id: row.id });
|
||||
if (succeeded) {
|
||||
ElMessage.success('删除成功');
|
||||
refreshTable();
|
||||
}
|
||||
};
|
||||
|
||||
// 重置密码
|
||||
const isShowResetDialog = ref(false);
|
||||
const resetSubmitting = ref(false);
|
||||
{ prop: 'status', label: '状态', width: 90 },
|
||||
{ prop: 'createdTime', label: '生成时间', minWidth: 170, search: { el: 'date-picker', props: { type: 'daterange', valueFormat: 'YYYY-MM-DD' } } },
|
||||
{ prop: 'action', label: '操作', align: 'center', fixed: 'right', width: 300 },
|
||||
];
|
||||
const can = (action) => auths([`system:unit:${action}`, `permission:account:${action}`]);
|
||||
const isEnabled = (status) => status === 0 || status === 'Enable' || status === 'enable';
|
||||
const refresh = () => tableRef.value?.getTableList();
|
||||
const onPlatformChange = (name) => { router.replace({ query: { ...route.query, platform: name === 'scoring' ? 'scoring' : undefined } }); tableKey.value++; };
|
||||
const confirmDisable = async () => { try { await ElMessageBox.confirm('确认禁用后用户将禁止登录。你还要继续吗?', '这是进行一项操作时必须了解的重要信息', { confirmButtonText: '继续', cancelButtonText: '取消', type: 'warning' }); return true; } catch { return false; } };
|
||||
const setStatus = async (row) => { const disabling = isEnabled(row.status); if (disabling && !(await confirmDisable())) return; const { succeeded } = await unitaccountSetstatusPost({ id: row.id, status: disabling ? 1 : 0 }); if (succeeded) { ElMessage.success(disabling ? '禁用成功' : '启用成功'); refresh(); } };
|
||||
const deleteAccount = async (id) => { const { succeeded } = await unitaccountDeleteDelete({ id }); if (succeeded) { ElMessage.success('删除成功'); refresh(); } };
|
||||
const resetFormRef = ref();
|
||||
const resetForm = reactive({
|
||||
id: 0,
|
||||
unitName: '',
|
||||
account: '',
|
||||
password: '',
|
||||
});
|
||||
const resetRules = reactive({
|
||||
password: [
|
||||
{ required: true, message: '请输入新密码', trigger: 'blur' },
|
||||
{ min: 6, max: 32, message: '密码长度为6-32位', trigger: 'blur' },
|
||||
],
|
||||
});
|
||||
|
||||
const onOpenResetPassword = (row) => {
|
||||
resetForm.id = row.id;
|
||||
resetForm.unitName = row.unitName;
|
||||
resetForm.account = row.account;
|
||||
resetForm.password = '';
|
||||
isShowResetDialog.value = true;
|
||||
};
|
||||
|
||||
const onResetDialogClose = () => {
|
||||
resetFormRef.value?.resetFields();
|
||||
};
|
||||
|
||||
const onSubmitResetPassword = () => {
|
||||
resetFormRef.value?.validate(async (valid) => {
|
||||
if (!valid) return;
|
||||
|
||||
resetSubmitting.value = true;
|
||||
try {
|
||||
const { succeeded } = await unitaccountResetPasswordPatch({ id: resetForm.id, password: resetForm.password });
|
||||
if (succeeded) {
|
||||
ElMessage.success('重置密码成功');
|
||||
isShowResetDialog.value = false;
|
||||
}
|
||||
} finally {
|
||||
resetSubmitting.value = false;
|
||||
}
|
||||
});
|
||||
};
|
||||
const resetDialog = reactive({ visible: false, submitting: false, id: 0, account: '', password: '' });
|
||||
const resetRules = { password: [{ required: true, message: '请输入新密码', trigger: 'blur' }, { min: 6, max: 32, message: '密码长度为 6-32 位', trigger: 'blur' }] };
|
||||
const openReset = (row) => Object.assign(resetDialog, { visible: true, id: row.id, account: row.account, password: '' });
|
||||
const submitReset = async () => { if (!(await resetFormRef.value?.validate().catch(() => false))) return; resetDialog.submitting = true; try { const { succeeded } = await unitaccountResetPasswordPost({ id: resetDialog.id, password: resetDialog.password }); if (succeeded) { ElMessage.success('重置密码成功'); resetDialog.visible = false; } } finally { resetDialog.submitting = false; } };
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.system-unit-container {
|
||||
.unit-tabs {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user