工作台 8 项指标全部改为明确中文标题。

ISO 8601 时间统一转换为北京时间,例如 2026/07/23 22:29。
审计日志、缴费确认时间、通知发布时间统一使用时间格式化。
管理员层级、流程类型、录取状态等建立统一中文映射。
工作台、流程设计、学校管理等管理页面的装饰性英文标题改为中文。
历史审计记录中的 plan、draft 等枚举也会自动翻译。
更新静态资源版本号,避免浏览器继续使用旧缓存。
This commit is contained in:
2026-07-23 22:46:45 +08:00 Unverified
parent 9931c8e0d3
commit ef8222c138
15 changed files with 860 additions and 3627 deletions
@@ -196,6 +196,7 @@ internal static class AdminPublicationProjector
["summary"] = summary, ["summary"] = summary,
["author"] = "系统自动发布", ["author"] = "系统自动发布",
["publishedAt"] = publishedAt, ["publishedAt"] = publishedAt,
["publicVisible"] = visible,
["visible"] = visible, ["visible"] = visible,
["status"] = visible ? "visible" : "hidden" ["status"] = visible ? "visible" : "hidden"
}; };
@@ -82,13 +82,13 @@ async function copyCodes() {
<div class="security-stack"> <div class="security-stack">
<div v-if="error" class="form-error">{{ error }}</div> <div v-if="error" class="form-error">{{ error }}</div>
<section v-if="recoveryCodes.length" class="recovery-code-panel"> <section v-if="recoveryCodes.length" class="recovery-code-panel">
<div><p>RECOVERY CODES</p><h2>立即保存恢复码</h2><span>每个恢复码只能使用一次关闭页面后系统不会再次展示本组代码</span></div> <div><p>账户恢复</p><h2>立即保存恢复码</h2><span>每个恢复码只能使用一次关闭页面后系统不会再次展示本组代码</span></div>
<div class="recovery-code-grid"><code v-for="code in recoveryCodes" :key="code">{{ code }}</code></div> <div class="recovery-code-grid"><code v-for="code in recoveryCodes" :key="code">{{ code }}</code></div>
<button class="app-button app-button--primary" type="button" @click="copyCodes">复制全部恢复码</button> <button class="app-button app-button--primary" type="button" @click="copyCodes">复制全部恢复码</button>
</section> </section>
<form class="business-form security-card" @submit.prevent="changePassword"> <form class="business-form security-card" @submit.prevent="changePassword">
<header><div><p>LOGIN PASSWORD</p><h2>修改登录密码</h2></div><StatusBadge value="active" /></header> <header><div><p>登录安全</p><h2>修改登录密码</h2></div><StatusBadge value="active" /></header>
<span>账号{{ sessionStore.state.user?.username || sessionStore.state.user?.candidateNumber }}新密码至少 8 并应与当前密码不同</span> <span>账号{{ sessionStore.state.user?.username || sessionStore.state.user?.candidateNumber }}新密码至少 8 并应与当前密码不同</span>
<label><span>当前密码</span><input v-model="password.currentPassword" type="password" autocomplete="current-password" required></label> <label><span>当前密码</span><input v-model="password.currentPassword" type="password" autocomplete="current-password" required></label>
<label><span>新密码</span><input v-model="password.newPassword" type="password" autocomplete="new-password" minlength="8" required></label> <label><span>新密码</span><input v-model="password.newPassword" type="password" autocomplete="new-password" minlength="8" required></label>
@@ -97,7 +97,7 @@ async function copyCodes() {
</form> </form>
<section class="business-form security-card"> <section class="business-form security-card">
<header><div><p>TWO-STEP VERIFICATION</p><h2>TOTP 二次验证</h2></div><StatusBadge :value="status.enabled ? 'active' : 'disabled'" /></header> <header><div><p>二次验证</p><h2>动态验证码TOTP</h2></div><StatusBadge :value="status.enabled ? 'active' : 'disabled'" /></header>
<template v-if="!status.enabled && !setup"> <template v-if="!status.enabled && !setup">
<span>使用验证器应用生成的动态验证码为账号增加独立于密码的第二层保护</span> <span>使用验证器应用生成的动态验证码为账号增加独立于密码的第二层保护</span>
<form class="inline-security-form" @submit.prevent="beginSetup"><label><span>确认当前密码</span><input v-model="setupPassword" type="password" autocomplete="current-password" required></label><button class="app-button app-button--primary" :disabled="busy">开始绑定验证器</button></form> <form class="inline-security-form" @submit.prevent="beginSetup"><label><span>确认当前密码</span><input v-model="setupPassword" type="password" autocomplete="current-password" required></label><button class="app-button app-button--primary" :disabled="busy">开始绑定验证器</button></form>
@@ -1,13 +1,9 @@
<script setup> <script setup>
import { statusLabel } from '@/lib/business-labels';
const props = defineProps({ value: { type: [String, Boolean, Number], default: '' } }); const props = defineProps({ value: { type: [String, Boolean, Number], default: '' } });
const labels = {
approved: '已通过', pending: '待处理', rejected: '已退回', draft: '草稿', published: '已发布',
open: '开放中', upcoming: '即将开放', closed: '已结束', archived: '已归档', completed: '已完成',
active: '正常', disabled: '已停用', unpaid: '未缴费', paid: '已缴费', final: '正式录取',
school_review: '学校审核', withdrawal_pending: '退档待审', reported: '已报到', not_reported: '未报到'
};
</script> </script>
<template> <template>
<span :class="['status-badge', `is-${String(props.value).replaceAll('_', '-')}`]">{{ labels[props.value] || props.value || '—' }}</span> <span :class="['status-badge', `is-${String(props.value).replaceAll('_', '-')}`]">{{ statusLabel(props.value) }}</span>
</template> </template>
@@ -28,7 +28,7 @@ async function logout() {
<template> <template>
<div class="portal-shell"> <div class="portal-shell">
<aside :class="['portal-shell__sidebar', { 'is-open': open }]"> <aside :class="['portal-shell__sidebar', { 'is-open': open }]">
<RouterLink class="portal-shell__brand" to="/"><span></span><div><strong>衡准考试服务</strong><small>OPERATIONS CONSOLE</small></div></RouterLink> <RouterLink class="portal-shell__brand" to="/"><span></span><div><strong>衡准考试服务</strong><small>业务管理工作台</small></div></RouterLink>
<button class="portal-shell__close" type="button" aria-label="关闭菜单" @click="open = false">×</button> <button class="portal-shell__close" type="button" aria-label="关闭菜单" @click="open = false">×</button>
<p class="portal-shell__role">{{ roleTitle }}</p> <p class="portal-shell__role">{{ roleTitle }}</p>
<nav aria-label="业务导航"> <nav aria-label="业务导航">
@@ -49,7 +49,7 @@ async function logout() {
<div class="portal-shell__user"><i>{{ String(user.displayName || '用').slice(0, 1) }}</i><span><strong>{{ user.displayName || user.username }}</strong><small>{{ scope }}</small></span><button type="button" title="退出登录" @click="logout">退出</button></div> <div class="portal-shell__user"><i>{{ String(user.displayName || '用').slice(0, 1) }}</i><span><strong>{{ user.displayName || user.username }}</strong><small>{{ scope }}</small></span><button type="button" title="退出登录" @click="logout">退出</button></div>
</header> </header>
<section class="portal-shell__content"> <section class="portal-shell__content">
<header class="portal-page-heading"><div><p>{{ role === 'candidate' ? 'CANDIDATE SERVICE' : role === 'admission_school' ? 'SCHOOL ADMISSION' : 'EXAM OPERATIONS' }}</p><h1>{{ title }}</h1><span>{{ description }}</span></div><slot name="actions" /></header> <header class="portal-page-heading"><div><p>{{ role === 'candidate' ? '考生服务' : role === 'admission_school' ? '招生学校工作台' : '考试业务管理' }}</p><h1>{{ title }}</h1><span>{{ description }}</span></div><slot name="actions" /></header>
<slot /> <slot />
</section> </section>
</main> </main>
@@ -0,0 +1,85 @@
const adminLevelLabels = {
super: "超级管理员",
school: "校级管理员",
class: "班级管理员",
};
const workflowTypeLabels = {
profile_change: "考生资料变更",
registration_review: "考试报名审核",
center_change: "考点考场变更",
candidate_account_batch: "批量报名号申领",
score_appeal: "考生成绩复议",
};
const businessTypeLabels = {
plan: "招生计划",
qualification: "指标资格",
admission: "录取结果",
cutoff: "录取分数线",
reporting: "报到情况",
};
const statusLabels = {
approved: "已通过",
pending: "待处理",
rejected: "已退回",
draft: "草稿",
published: "已发布",
open: "开放中",
upcoming: "即将开放",
closed: "已结束",
archived: "已归档",
completed: "已完成",
active: "正常",
disabled: "已停用",
unpaid: "未缴费",
paid: "已缴费",
final: "正式录取",
admitted: "已接收",
school_review: "学校审核",
withdrawal_pending: "退档待审",
withdrawn: "已退档",
reported: "已报到",
not_reported: "未报到",
submitted: "已提交",
filling: "志愿填报中",
supplementary: "补录中",
locked: "已锁定",
visible: "已公开",
hidden: "已隐藏",
arranged: "已编排",
};
export function adminLevelLabel(value) {
return adminLevelLabels[value] || value || "—";
}
export function workflowTypeLabel(value) {
return workflowTypeLabels[value] || value || "—";
}
export function statusLabel(value) {
if (value === true) return "是";
if (value === false) return "否";
return statusLabels[value] || value || "—";
}
export function businessValueLabel(value) {
return (
adminLevelLabels[value] ||
workflowTypeLabels[value] ||
businessTypeLabels[value] ||
statusLabels[value] ||
value ||
"—"
);
}
export function auditDetailLabel(value) {
if (!value) return "—";
return String(value)
.split(" · ")
.map((part) => businessValueLabel(part))
.join(" · ");
}
+2
View File
@@ -3,6 +3,7 @@ export function formatDate(value, withTime = false) {
const date = new Date(value); const date = new Date(value);
if (Number.isNaN(date.getTime())) return String(value); if (Number.isNaN(date.getTime())) return String(value);
return new Intl.DateTimeFormat('zh-CN', { return new Intl.DateTimeFormat('zh-CN', {
timeZone: 'Asia/Shanghai',
year: 'numeric', year: 'numeric',
month: '2-digit', month: '2-digit',
day: '2-digit', day: '2-digit',
@@ -18,6 +19,7 @@ export function dateRange(start, end) {
if (!endDate || Number.isNaN(endDate.getTime())) return formatDate(start); if (!endDate || Number.isNaN(endDate.getTime())) return formatDate(start);
const day = value => new Intl.DateTimeFormat('zh-CN', { const day = value => new Intl.DateTimeFormat('zh-CN', {
timeZone: 'Asia/Shanghai',
year: 'numeric', month: '2-digit', day: '2-digit' year: 'numeric', month: '2-digit', day: '2-digit'
}).format(value); }).format(value);
return `${day(startDate)}${day(endDate)}`; return `${day(startDate)}${day(endDate)}`;
File diff suppressed because it is too large Load Diff
@@ -456,7 +456,7 @@ hydrateSetting();
<div v-if="error" class="form-error">{{ error }}</div> <div v-if="error" class="form-error">{{ error }}</div>
<section v-if="issued" class="issued-credential"> <section v-if="issued" class="issued-credential">
<div> <div>
<span>ONE-TIME CREDENTIAL</span> <span>一次性登录凭据</span>
<h2>招生学校临时密码</h2> <h2>招生学校临时密码</h2>
<p>关闭后不再展示请安全交付</p> <p>关闭后不再展示请安全交付</p>
</div> </div>
@@ -474,7 +474,7 @@ hydrateSetting();
</section> </section>
<section class="admission-command-banner"> <section class="admission-command-banner">
<div> <div>
<span>ADMISSION COMMAND</span> <span>招生工作总览</span>
<h2>中考招生录取控制台</h2> <h2>中考招生录取控制台</h2>
<p> <p>
志愿内容仅超级管理员可见且不可代改投档和录取变更全部进入审计日志 志愿内容仅超级管理员可见且不可代改投档和录取变更全部进入审计日志
@@ -487,7 +487,7 @@ hydrateSetting();
class="business-form admission-admin-setting" class="business-form admission-admin-setting"
@submit.prevent="saveSetting" @submit.prevent="saveSetting"
> >
<p>EXAM PREFERENCE SETTING</p> <p>考试志愿设置</p>
<h2>考试志愿与录取阶段</h2> <h2>考试志愿与录取阶段</h2>
<label <label
><span>考试</span ><span>考试</span
@@ -612,7 +612,7 @@ hydrateSetting();
<template v-else-if="page === 'admission-accounts'"> <template v-else-if="page === 'admission-accounts'">
<form class="business-form" @submit.prevent="createAccount"> <form class="business-form" @submit.prevent="createAccount">
<p>ADMISSION SCHOOL ACCOUNT</p> <p>招生学校账号</p>
<h2>创建招生学校账户</h2> <h2>创建招生学校账户</h2>
<div class="form-grid"> <div class="form-grid">
<label <label
@@ -751,7 +751,7 @@ hydrateSetting();
<template v-else-if="page === 'admission-plans'"> <template v-else-if="page === 'admission-plans'">
<form class="business-form" @submit.prevent="submitPlan"> <form class="business-form" @submit.prevent="submitPlan">
<p>PLAN ON BEHALF</p> <p>代报招生计划</p>
<h2>代上传招生计划</h2> <h2>代上传招生计划</h2>
<div class="form-grid"> <div class="form-grid">
<label <label
@@ -7,7 +7,9 @@ import StatusBadge from "@/components/common/StatusBadge.vue";
import QualificationLedger from "@/components/admin/QualificationLedger.vue"; import QualificationLedger from "@/components/admin/QualificationLedger.vue";
import { useLedger } from "@/composables/useLedger"; import { useLedger } from "@/composables/useLedger";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import { adminLevelLabel, auditDetailLabel } from "@/lib/business-labels";
import { downloadSelectedExcel } from "@/lib/export-selection"; import { downloadSelectedExcel } from "@/lib/export-selection";
import { formatDate } from "@/lib/format";
import { sessionStore } from "@/stores/session"; import { sessionStore } from "@/stores/session";
import { uiStore } from "@/stores/ui"; import { uiStore } from "@/stores/ui";
@@ -47,6 +49,24 @@ const candidateSelected = ref([]);
const registrationSelected = ref([]); const registrationSelected = ref([]);
const paymentSelected = ref([]); const paymentSelected = ref([]);
const dashboardMetricDefinitions = [
["candidates", "考生总数"],
["pendingCandidates", "资料待审核"],
["registrations", "报名记录"],
["pendingRegistrations", "报名待审核"],
["pendingPayments", "待缴费"],
["pendingFlows", "待办流程"],
["publishedExams", "已发布考试"],
["notices", "通知公告"],
];
const dashboardMetrics = computed(() =>
dashboardMetricDefinitions.map(([key, label]) => ({
key,
label,
value: props.data.metrics?.[key] ?? 0,
})),
);
const classById = computed( const classById = computed(
() => new Map((props.data.classes || []).map((item) => [item.id, item])), () => new Map((props.data.classes || []).map((item) => [item.id, item])),
); );
@@ -634,7 +654,7 @@ async function importExcel(resource, payload) {
<div v-if="error" class="form-error">{{ error }}</div> <div v-if="error" class="form-error">{{ error }}</div>
<section v-if="issued" class="issued-credential"> <section v-if="issued" class="issued-credential">
<div> <div>
<span>ONE-TIME CREDENTIAL</span> <span>一次性登录凭据</span>
<h2>{{ issued.title }}</h2> <h2>{{ issued.title }}</h2>
<p>请通过线下安全渠道交付关闭后不再展示</p> <p>请通过线下安全渠道交付关闭后不再展示</p>
</div> </div>
@@ -653,16 +673,16 @@ async function importExcel(resource, payload) {
<template v-if="page === 'dashboard'"> <template v-if="page === 'dashboard'">
<section class="scope-banner-vue"> <section class="scope-banner-vue">
<span>{{ sessionStore.state.user?.adminLevel }}</span> <span>{{ adminLevelLabel(sessionStore.state.user?.adminLevel) }}</span>
<div> <div>
<strong>{{ data.scopeLabel }}</strong <strong>{{ data.scopeLabel }}</strong
><small>以下指标已按当前管理员数据范围过滤</small> ><small>以下指标已按当前管理员数据范围过滤</small>
</div> </div>
</section> </section>
<section class="record-metrics"> <section class="record-metrics">
<article v-for="(value, key) in data.metrics" :key="key"> <article v-for="metric in dashboardMetrics" :key="metric.key">
<span>{{ key }}</span <span>{{ metric.label }}</span
><strong>{{ value }}</strong> ><strong>{{ metric.value }}</strong>
</article> </article>
</section> </section>
<section class="record-panel audit-ledger"> <section class="record-panel audit-ledger">
@@ -675,9 +695,9 @@ async function importExcel(resource, payload) {
<div v-for="log in data.logs" :key="log.id" class="dashboard-row"> <div v-for="log in data.logs" :key="log.id" class="dashboard-row">
<b>{{ String(log.actorName || "系").slice(0, 1) }}</b <b>{{ String(log.actorName || "系").slice(0, 1) }}</b
><span ><span
><strong>{{ log.actorName }} · {{ log.action }}</strong ><strong>{{ log.actorName || "系统" }} · {{ log.action }}</strong
><small>{{ log.detail }}</small></span ><small>{{ auditDetailLabel(log.detail) }}</small></span
><time>{{ log.createdAt }}</time> ><time>{{ formatDate(log.createdAt, true) }}</time>
</div> </div>
</section> </section>
</template> </template>
@@ -689,7 +709,7 @@ async function importExcel(resource, payload) {
> >
<header> <header>
<div> <div>
<p>ORGANIZATION</p> <p>组织机构</p>
<h2>新增学校档案</h2> <h2>新增学校档案</h2>
</div> </div>
</header> </header>
@@ -910,7 +930,7 @@ async function importExcel(resource, payload) {
> >
<header> <header>
<div> <div>
<p>ACCOUNT AUTHORITY</p> <p>账号权限</p>
<h2>创建管理员</h2> <h2>创建管理员</h2>
</div> </div>
<button <button
@@ -1019,7 +1039,7 @@ async function importExcel(resource, payload) {
<tr v-for="item in adminLedger.rows" :key="item.id"> <tr v-for="item in adminLedger.rows" :key="item.id">
<td>{{ item.displayName }}</td> <td>{{ item.displayName }}</td>
<td>{{ item.username }}</td> <td>{{ item.username }}</td>
<td>{{ item.levelName || item.adminLevel }}</td> <td>{{ item.levelName || adminLevelLabel(item.adminLevel) }}</td>
<td> <td>
{{ item.schoolName || "全局" }} {{ item.className || "" }} {{ item.schoolName || "全局" }} {{ item.className || "" }}
</td> </td>
@@ -1060,7 +1080,7 @@ async function importExcel(resource, payload) {
@import="importExcel('account_quotas', $event)" @import="importExcel('account_quotas', $event)"
/> />
<form class="business-form" @submit.prevent="submitBatch"> <form class="business-form" @submit.prevent="submitBatch">
<p>SCHOOL ACCOUNT REQUEST</p> <p>报名号申领</p>
<h2>按班级申领报名号</h2> <h2>按班级申领报名号</h2>
<div class="quota-grid-vue"> <div class="quota-grid-vue">
<label v-for="item in data.classes" :key="item.id" <label v-for="item in data.classes" :key="item.id"
@@ -1159,7 +1179,7 @@ async function importExcel(resource, payload) {
@submit.prevent="archiveCandidates" @submit.prevent="archiveCandidates"
> >
<div> <div>
<p>SCHOOL ACCOUNT ARCHIVE</p> <p>账号归档</p>
<h2>按班级或年级归档账户</h2> <h2>按班级或年级归档账户</h2>
<span>只冻结登录不删除报名准考证成绩和审计记录</span> <span>只冻结登录不删除报名准考证成绩和审计记录</span>
</div> </div>
@@ -1731,7 +1751,7 @@ async function importExcel(resource, payload) {
<td>¥ {{ Number(item.amountDue || 0).toFixed(2) }}</td> <td>¥ {{ Number(item.amountDue || 0).toFixed(2) }}</td>
<td><StatusBadge :value="item.paymentStatus" /></td> <td><StatusBadge :value="item.paymentStatus" /></td>
<td> <td>
{{ item.paidAt || "—" }}<small>{{ item.paidByName }}</small> {{ formatDate(item.paidAt, true) }}<small>{{ item.paidByName }}</small>
</td> </td>
<td> <td>
<template <template
@@ -636,7 +636,7 @@ onMounted(() => {
> >
<header> <header>
<div> <div>
<p>NEW EXAM</p> <p>新建考试</p>
<h2>创建考试与科目</h2> <h2>创建考试与科目</h2>
<span>先定义科目计分再选择整场合格判定方式</span> <span>先定义科目计分再选择整场合格判定方式</span>
</div> </div>
@@ -824,7 +824,7 @@ onMounted(() => {
v-if="data.canArrange" v-if="data.canArrange"
class="business-form arrangement-console-vue" class="business-form arrangement-console-vue"
> >
<p>ADMIT ARRANGEMENT</p> <p>准考证编排</p>
<h2>整场准考证编排</h2> <h2>整场准考证编排</h2>
<div class="form-grid"> <div class="form-grid">
<label <label
@@ -6,6 +6,7 @@ import LedgerPager from "@/components/common/LedgerPager.vue";
import StatusBadge from "@/components/common/StatusBadge.vue"; import StatusBadge from "@/components/common/StatusBadge.vue";
import { useLedger } from "@/composables/useLedger"; import { useLedger } from "@/composables/useLedger";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import { adminLevelLabel, workflowTypeLabel } from "@/lib/business-labels";
import { downloadSelectedExcel } from "@/lib/export-selection"; import { downloadSelectedExcel } from "@/lib/export-selection";
import { formatDate } from "@/lib/format"; import { formatDate } from "@/lib/format";
import { sessionStore } from "@/stores/session"; import { sessionStore } from "@/stores/session";
@@ -140,7 +141,9 @@ const noticeLedger = useLedger(() => props.data.notices || [], {
const publicationLedger = useLedger(() => props.data.publications || [], { const publicationLedger = useLedger(() => props.data.publications || [], {
filters: { filters: {
visibility: (item, value) => visibility: (item, value) =>
value === "visible" ? item.publicVisible : !item.publicVisible, value === "visible"
? isPublicationVisible(item)
: !isPublicationVisible(item),
type: (item, value) => item.sourceType === value, type: (item, value) => item.sourceType === value,
}, },
searchText: (item) => searchText: (item) =>
@@ -176,6 +179,11 @@ function publicationTypeLabel(value) {
}[value] || value }[value] || value
); );
} }
function isPublicationVisible(item) {
if (typeof item?.publicVisible === "boolean") return item.publicVisible;
if (typeof item?.visible === "boolean") return item.visible;
return item?.status === "visible";
}
const flowTypes = computed(() => [ const flowTypes = computed(() => [
...new Set( ...new Set(
(props.data.instances || []) (props.data.instances || [])
@@ -285,13 +293,14 @@ function toggleNotice(item) {
); );
} }
function togglePublication(item) { function togglePublication(item) {
const currentlyVisible = isPublicationVisible(item);
act( act(
() => () =>
api(`/api/admin/publications/${item.sourceType}/${item.id}`, { api(`/api/admin/publications/${item.sourceType}/${item.id}`, {
method: "PATCH", method: "PATCH",
body: { publicVisible: !item.publicVisible }, body: { publicVisible: !currentlyVisible },
}), }),
item.publicVisible ? "系统公示已隐藏" : "系统公示已公开", currentlyVisible ? "系统公示已隐藏" : "系统公示已公开",
); );
} }
function addRoom() { function addRoom() {
@@ -571,7 +580,7 @@ if (props.page === "number-rules" && props.data.activeRule) {
> >
<header class="notice-editor-studio__header"> <header class="notice-editor-studio__header">
<div> <div>
<p>OFFICIAL NOTICE DESK</p> <p>官方通知发布台</p>
<h2>{{ notice.id ? "编辑人工通知" : "起草新的通知公告" }}</h2> <h2>{{ notice.id ? "编辑人工通知" : "起草新的通知公告" }}</h2>
<span <span
>正文由 CKEditor 编辑发布前由服务端统一清理不安全内容</span >正文由 CKEditor 编辑发布前由服务端统一清理不安全内容</span
@@ -835,8 +844,8 @@ if (props.page === "number-rules" && props.data.activeRule) {
<tr> <tr>
<th>标题</th> <th>标题</th>
<th>类型</th> <th>类型</th>
<th>状态</th> <th>生成方式</th>
<th>公开</th> <th>公开状态</th>
<th>操作</th> <th>操作</th>
</tr> </tr>
</thead> </thead>
@@ -847,15 +856,27 @@ if (props.page === "number-rules" && props.data.activeRule) {
> >
<td>{{ item.title }}</td> <td>{{ item.title }}</td>
<td>{{ publicationTypeLabel(item.sourceType) }}</td> <td>{{ publicationTypeLabel(item.sourceType) }}</td>
<td><StatusBadge :value="item.status" /></td> <td>系统自动生成</td>
<td>{{ item.publicVisible ? "公开" : "隐藏" }}</td> <td>
<span
class="status-badge"
:class="
isPublicationVisible(item)
? 'is-approved'
: 'is-archived'
"
>{{
isPublicationVisible(item) ? "正在公开" : "已隐藏"
}}</span
>
</td>
<td> <td>
<button <button
type="button" type="button"
class="table-action" class="table-action"
@click="togglePublication(item)" @click="togglePublication(item)"
> >
{{ item.publicVisible ? "隐藏" : "公开" }} {{ isPublicationVisible(item) ? "隐藏" : "公开" }}
</button> </button>
</td> </td>
</tr> </tr>
@@ -881,7 +902,7 @@ if (props.page === "number-rules" && props.data.activeRule) {
class="business-form center-editor-vue" class="business-form center-editor-vue"
@submit.prevent="saveCenter" @submit.prevent="saveCenter"
> >
<p>CONTROLLED DOSSIER</p> <p>受控考点档案</p>
<h2>提交新考点档案</h2> <h2>提交新考点档案</h2>
<span>提交后进入考点考场变更审批通过前不会改动正式档案</span <span>提交后进入考点考场变更审批通过前不会改动正式档案</span
><label v-if="sessionStore.state.user?.adminLevel === 'super'" ><label v-if="sessionStore.state.user?.adminLevel === 'super'"
@@ -1238,7 +1259,7 @@ if (props.page === "number-rules" && props.data.activeRule) {
><select v-model="flowLedger.filters.type"> ><select v-model="flowLedger.filters.type">
<option value="">全部类型</option> <option value="">全部类型</option>
<option v-for="type in flowTypes" :key="type" :value="type"> <option v-for="type in flowTypes" :key="type" :value="type">
{{ type }} {{ workflowTypeLabel(type) }}
</option> </option>
</select></label </select></label
><label ><label
@@ -1261,7 +1282,7 @@ if (props.page === "number-rules" && props.data.activeRule) {
> >
<header> <header>
<div> <div>
<span>{{ item.businessType }}</span> <span>{{ workflowTypeLabel(item.businessType) }}</span>
<h2> <h2>
{{ {{
item.candidateName || item.candidateName ||
@@ -1288,7 +1309,7 @@ if (props.page === "number-rules" && props.data.activeRule) {
step.position < item.currentStep ? "✓" : step.position step.position < item.currentStep ? "✓" : step.position
}}</i }}</i
><b>{{ step.name }}</b ><b>{{ step.name }}</b
><small>{{ step.adminLevel }}</small></span ><small>{{ adminLevelLabel(step.adminLevel) }}</small></span
> >
</div> </div>
<footer> <footer>
@@ -1337,7 +1358,7 @@ if (props.page === "number-rules" && props.data.activeRule) {
> >
<header> <header>
<div> <div>
<p>{{ workflow.businessType }}</p> <p>{{ workflowTypeLabel(workflow.businessType) }}</p>
<h2>{{ workflow.name }}</h2> <h2>{{ workflow.name }}</h2>
</div> </div>
<button <button
@@ -1374,7 +1395,7 @@ if (props.page === "number-rules" && props.data.activeRule) {
<template v-else-if="page === 'number-rules'" <template v-else-if="page === 'number-rules'"
><section class="account-number-principle-vue"> ><section class="account-number-principle-vue">
<span>ONE CANDIDATE · ONE NUMBER</span> <span>一名考生 · 一个报名号</span>
<h2>超级管理员只设计号码规则</h2> <h2>超级管理员只设计号码规则</h2>
<p>学校按班级提交申领最终批准后系统才创建长期考生账户</p> <p>学校按班级提交申领最终批准后系统才创建长期考生账户</p>
</section> </section>
@@ -1401,7 +1422,7 @@ if (props.page === "number-rules" && props.data.activeRule) {
><input v-model="rule.school_code" type="checkbox" /> ><input v-model="rule.school_code" type="checkbox" />
学校代码</label 学校代码</label
><label ><label
><input v-model="rule.gender" type="checkbox" /> 性别 M/F/X</label ><input v-model="rule.gender" type="checkbox" /> 性别代码M/F/X</label
><label ><label
><input v-model="rule.literal" type="checkbox" /> 固定值 ><input v-model="rule.literal" type="checkbox" /> 固定值
<input v-model="rule.literalValue" /></label <input v-model="rule.literalValue" /></label
@@ -8,6 +8,7 @@ import ProfileFields from '@/components/common/ProfileFields.vue';
import AccountSecurity from '@/components/common/AccountSecurity.vue'; import AccountSecurity from '@/components/common/AccountSecurity.vue';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { dateRange, formatDate, money, passPolicyText } from '@/lib/format'; import { dateRange, formatDate, money, passPolicyText } from '@/lib/format';
import { statusLabel } from '@/lib/business-labels';
import { sessionStore } from '@/stores/session'; import { sessionStore } from '@/stores/session';
import { uiStore } from '@/stores/ui'; import { uiStore } from '@/stores/ui';
@@ -186,13 +187,13 @@ onMounted(load);
<div v-else-if="page === 'exams'" class="business-card-list"><article v-for="exam in data.exams" :key="exam.id" class="exam-apply-card"><header><span>{{ exam.code }}</span><StatusBadge :value="exam.registrationState" /></header><h2>{{ exam.name }}</h2><p>{{ exam.description }}</p><dl><div><dt>报名期限</dt><dd>{{ dateRange(exam.registrationStart, exam.registrationEnd) }}</dd></div><div><dt>考试时间</dt><dd>{{ dateRange(exam.examStart, exam.examEnd) }}</dd></div><div><dt>计分规则</dt><dd>总分 {{ exam.totalScore }} · {{ passPolicyText(exam) }}</dd></div></dl><div class="subject-choice-grid"><label v-for="subject in exam.subjects" :key="subject.id"><input v-model="selectedSubjects[exam.id]" type="checkbox" :value="subject.id" :disabled="Boolean(exam.registration)"><span><strong>{{ subject.name }}</strong><small>{{ subject.date }} {{ subject.start }} · 满分 {{ subject.fullScore }}</small><em>{{ money(subject.fee) }}</em></span></label></div><footer v-if="exam.registration"><span>已提交 {{ exam.registration.subjectIds?.length || 0 }} 个科目</span><StatusBadge :value="exam.registration.status" /></footer><button v-else class="app-button app-button--primary" type="button" :disabled="saving || exam.registrationState !== 'open' || data.profileStatus !== 'approved'" @click="registerExam(exam)">{{ data.profileStatus !== 'approved' ? '资料审核通过后可报名' : '提交考试报名' }}</button></article></div> <div v-else-if="page === 'exams'" class="business-card-list"><article v-for="exam in data.exams" :key="exam.id" class="exam-apply-card"><header><span>{{ exam.code }}</span><StatusBadge :value="exam.registrationState" /></header><h2>{{ exam.name }}</h2><p>{{ exam.description }}</p><dl><div><dt>报名期限</dt><dd>{{ dateRange(exam.registrationStart, exam.registrationEnd) }}</dd></div><div><dt>考试时间</dt><dd>{{ dateRange(exam.examStart, exam.examEnd) }}</dd></div><div><dt>计分规则</dt><dd>总分 {{ exam.totalScore }} · {{ passPolicyText(exam) }}</dd></div></dl><div class="subject-choice-grid"><label v-for="subject in exam.subjects" :key="subject.id"><input v-model="selectedSubjects[exam.id]" type="checkbox" :value="subject.id" :disabled="Boolean(exam.registration)"><span><strong>{{ subject.name }}</strong><small>{{ subject.date }} {{ subject.start }} · 满分 {{ subject.fullScore }}</small><em>{{ money(subject.fee) }}</em></span></label></div><footer v-if="exam.registration"><span>已提交 {{ exam.registration.subjectIds?.length || 0 }} 个科目</span><StatusBadge :value="exam.registration.status" /></footer><button v-else class="app-button app-button--primary" type="button" :disabled="saving || exam.registrationState !== 'open' || data.profileStatus !== 'approved'" @click="registerExam(exam)">{{ data.profileStatus !== 'approved' ? '资料审核通过后可报名' : '提交考试报名' }}</button></article></div>
<div v-else-if="page === 'registrations'" class="business-card-list"><article v-for="item in registrations" :key="item.id" class="registration-vue-card"><header><div><span>{{ item.exam?.code }}</span><h2>{{ item.exam?.name }}</h2></div><StatusBadge :value="item.exam?.archivedAt ? 'archived' : item.status" /></header><dl><div><dt>账户报名号</dt><dd>{{ item.registrationNumber || sessionStore.state.user?.candidateNumber }}</dd></div><div><dt>当前审批</dt><dd>{{ item.workflow?.currentStepDetail?.name || item.workflow?.status || '待提交' }}</dd></div><div><dt>应缴金额</dt><dd>{{ money(item.amountDue) }}</dd></div><div><dt>缴费状态</dt><dd><StatusBadge :value="item.paymentStatus" /></dd></div></dl><div class="chip-list"><span v-for="subject in item.subjects" :key="subject.id">{{ subject.name }}<small>{{ subject.date }} {{ subject.start }}</small></span></div><p v-if="item.reviewNote">审核意见:{{ item.reviewNote }}</p></article><div v-if="!registrations.length" class="page-state page-state--empty"><strong>还没有考试报名</strong><p>资料审核通过后可在考试报名中选择考试与科目</p></div></div> <div v-else-if="page === 'registrations'" class="business-card-list"><article v-for="item in registrations" :key="item.id" class="registration-vue-card"><header><div><span>{{ item.exam?.code }}</span><h2>{{ item.exam?.name }}</h2></div><StatusBadge :value="item.exam?.archivedAt ? 'archived' : item.status" /></header><dl><div><dt>账户报名号</dt><dd>{{ item.registrationNumber || sessionStore.state.user?.candidateNumber }}</dd></div><div><dt>当前审批</dt><dd>{{ item.workflow?.currentStepDetail?.name || (item.workflow?.status ? statusLabel(item.workflow.status) : '待提交') }}</dd></div><div><dt>应缴金额</dt><dd>{{ money(item.amountDue) }}</dd></div><div><dt>缴费状态</dt><dd><StatusBadge :value="item.paymentStatus" /></dd></div></dl><div class="chip-list"><span v-for="subject in item.subjects" :key="subject.id">{{ subject.name }}<small>{{ subject.date }} {{ subject.start }}</small></span></div><p v-if="item.reviewNote">审核意见:{{ item.reviewNote }}</p></article><div v-if="!registrations.length" class="page-state page-state--empty"><strong>还没有考试报名</strong><p>资料审核通过后可在考试报名中选择考试与科目</p></div></div>
<div v-else-if="page === 'admit'" class="business-card-list"><article v-for="item in registrations.filter(row => row.admitCard)" :key="item.id" class="admit-card-vue"><header><span>{{ item.exam.code }}</span><StatusBadge :value="item.exam.archivedAt ? 'archived' : 'open'" /></header><h2>{{ item.exam.name }}</h2><div><small>准考证号</small><strong>{{ item.admitCard.number }}</strong></div><dl><div><dt>固定考点</dt><dd>{{ item.admitCard.testCenter }}</dd></div><div><dt>下载时间</dt><dd>{{ dateRange(item.exam.admitDownloadStart, item.exam.admitDownloadEnd) }}</dd></div></dl><button class="app-button app-button--primary" type="button" :disabled="Boolean(item.exam.archivedAt)" @click="downloadAdmit(item.id)">下载准考证</button></article><div v-if="!registrations.some(row => row.admitCard)" class="page-state page-state--empty"><strong>准考证尚未生成</strong><p>管理员统一编排后会显示在这里</p></div></div> <div v-else-if="page === 'admit'" class="business-card-list"><article v-for="item in registrations.filter(row => row.admitCard)" :key="item.id" class="admit-card-vue"><header><span>{{ item.exam.code }}</span><StatusBadge :value="item.exam.archivedAt ? 'archived' : 'open'" /></header><h2>{{ item.exam.name }}</h2><div><small>准考证号</small><strong>{{ item.admitCard.number }}</strong></div><dl><div><dt>固定考点</dt><dd>{{ item.admitCard.testCenter }}</dd></div><div><dt>下载时间</dt><dd>{{ dateRange(item.exam.admitDownloadStart, item.exam.admitDownloadEnd) }}</dd></div></dl><button class="app-button app-button--primary" type="button" :disabled="Boolean(item.exam.archivedAt)" @click="downloadAdmit(item.id)">下载准考证</button></article><div v-if="!registrations.some(row => row.admitCard)" class="page-state page-state--empty"><strong>准考证尚未生成</strong><p>管理员统一编排后会显示在这里</p></div></div>
<div v-else-if="page === 'results'" class="business-card-list"><section v-for="items in groupedResults" :key="items[0].examId" class="record-panel result-group"><header><div><span>{{ items[0].examCode }}</span><h2>{{ items[0].examName }}</h2></div><button class="app-button app-button--primary" type="button" @click="downloadScore(items)">下载 PDF 成绩单</button></header><div class="result-card-grid"><article v-for="result in items" :key="result.id"><span>{{ result.subjectName }}</span><strong>{{ result.score }}<small>/ {{ result.fullScore }}</small></strong><em>{{ result.grade }} · 第 {{ result.rank }} / {{ result.cohortSize }} 名</em><StatusBadge v-if="result.appeal" :value="result.appeal.status" /><form v-else @submit.prevent="submitAppeal(result)"><textarea v-model="appealReasons[result.id]" rows="2" placeholder="填写成绩复议理由"></textarea><button type="submit">申请复议</button></form></article></div></section><div v-if="!groupedResults.length" class="page-state page-state--empty"><strong>暂时没有已发布成绩</strong><p>成绩发布后会显示在这里</p></div></div> <div v-else-if="page === 'results'" class="business-card-list"><section v-for="items in groupedResults" :key="items[0].examId" class="record-panel result-group"><header><div><span>{{ items[0].examCode }}</span><h2>{{ items[0].examName }}</h2></div><button class="app-button app-button--primary" type="button" @click="downloadScore(items)">下载 PDF 成绩单</button></header><div class="result-card-grid"><article v-for="result in items" :key="result.id"><span>{{ result.subjectName }}</span><strong>{{ result.score }}<small>/ {{ result.fullScore }}</small></strong><em>{{ result.grade }} · 第 {{ result.rank }} / {{ result.cohortSize }} 名</em><StatusBadge v-if="result.appeal" :value="result.appeal.status" /><form v-else @submit.prevent="submitAppeal(result)"><textarea v-model="appealReasons[result.id]" rows="2" placeholder="填写成绩复议理由"></textarea><button type="submit">申请复议</button></form></article></div></section><div v-if="!groupedResults.length" class="page-state page-state--empty"><strong>暂时没有已发布成绩</strong><p>成绩发布后会显示在这里</p></div></div>
<div v-else-if="page === 'admissions'" class="business-card-list"><section v-for="item in data.admissions" :key="item.examId" class="record-panel admission-candidate-vue"><header><div><span>{{ item.exam.code }} · 第 {{ item.payload?.round || 1 }} 轮</span><h2>{{ item.exam.name }}</h2></div><StatusBadge :value="item.status" /></header><div class="record-metrics"><article><span>本场总成绩</span><strong>{{ item.totalScore ?? '未完整发布' }}</strong></article><article><span>特征分</span><strong>{{ item.featureScore || 0 }}</strong></article><article><span>已提交志愿</span><strong>{{ item.submissionCount || 0 }} / {{ item.maxSubmissions || item.payload?.maxSubmissions || 0 }}</strong></article></div><div v-if="item.placement" class="form-callout"><strong>当前录取结果:{{ item.placementSchool?.name || item.placement.schoolName || '招生学校' }}</strong><p>{{ item.placement.payload?.categoryName }} · {{ item.placement.status }}</p><button v-if="item.placement.status === 'final'" class="app-button app-button--primary" type="button" @click="downloadAdmission(item)">下载录取通知书 PDF</button></div><form v-if="['filling','supplementary'].includes(item.status) && !item.preferenceLocked && item.supplementEligible !== false" class="preference-editor" @submit.prevent="savePreferences(item)"><div v-for="(choice, index) in preferenceDrafts[item.examId]" :key="index" class="preference-row"><b>{{ choice.preferenceType === 'indicator' ? '指标' : index }}</b><select v-model="choice.schoolId" @change="choice.categoryCode = ''"><option value="">选择招生学校</option><option v-for="plan in availablePlans(item, choice.preferenceType)" :key="plan.schoolId" :value="plan.schoolId">{{ plan.schoolCode }} · {{ plan.schoolName }}</option></select><select v-model="choice.categoryCode" :disabled="!choice.schoolId"><option value="">选择招生类别</option><option v-for="category in categoriesFor(item, choice)" :key="category.code" :value="category.code">{{ category.name }}</option></select></div><button class="app-button app-button--primary">保存本人志愿</button></form><p v-else>{{ item.supplementIneligibilityReason || item.payload?.progress || '当前阶段不能修改志愿。' }}</p></section><div v-if="!data.admissions?.length" class="page-state page-state--empty"><strong>暂无志愿填报安排</strong><p>成绩发布且考试启用志愿后会显示在这里</p></div></div> <div v-else-if="page === 'admissions'" class="business-card-list"><section v-for="item in data.admissions" :key="item.examId" class="record-panel admission-candidate-vue"><header><div><span>{{ item.exam.code }} · 第 {{ item.payload?.round || 1 }} 轮</span><h2>{{ item.exam.name }}</h2></div><StatusBadge :value="item.status" /></header><div class="record-metrics"><article><span>本场总成绩</span><strong>{{ item.totalScore ?? '未完整发布' }}</strong></article><article><span>特征分</span><strong>{{ item.featureScore || 0 }}</strong></article><article><span>已提交志愿</span><strong>{{ item.submissionCount || 0 }} / {{ item.maxSubmissions || item.payload?.maxSubmissions || 0 }}</strong></article></div><div v-if="item.placement" class="form-callout"><strong>当前录取结果:{{ item.placementSchool?.name || item.placement.schoolName || '招生学校' }}</strong><p>{{ item.placement.payload?.categoryName }} · {{ statusLabel(item.placement.status) }}</p><button v-if="item.placement.status === 'final'" class="app-button app-button--primary" type="button" @click="downloadAdmission(item)">下载录取通知书 PDF</button></div><form v-if="['filling','supplementary'].includes(item.status) && !item.preferenceLocked && item.supplementEligible !== false" class="preference-editor" @submit.prevent="savePreferences(item)"><div v-for="(choice, index) in preferenceDrafts[item.examId]" :key="index" class="preference-row"><b>{{ choice.preferenceType === 'indicator' ? '指标' : index }}</b><select v-model="choice.schoolId" @change="choice.categoryCode = ''"><option value="">选择招生学校</option><option v-for="plan in availablePlans(item, choice.preferenceType)" :key="plan.schoolId" :value="plan.schoolId">{{ plan.schoolCode }} · {{ plan.schoolName }}</option></select><select v-model="choice.categoryCode" :disabled="!choice.schoolId"><option value="">选择招生类别</option><option v-for="category in categoriesFor(item, choice)" :key="category.code" :value="category.code">{{ category.name }}</option></select></div><button class="app-button app-button--primary">保存本人志愿</button></form><p v-else>{{ item.supplementIneligibilityReason || item.payload?.progress || '当前阶段不能修改志愿。' }}</p></section><div v-if="!data.admissions?.length" class="page-state page-state--empty"><strong>暂无志愿填报安排</strong><p>成绩发布且考试启用志愿后会显示在这里</p></div></div>
<section v-else-if="page === 'notices'" class="record-panel notice-list-vue"><button v-for="notice in data.notices" :key="notice.id" type="button" @click="openNotice(notice.id)"><time>{{ formatDate(notice.publishAt) }}</time><span><em>{{ notice.category }}</em><strong>{{ notice.title }}</strong><small>{{ notice.summary }}</small></span><i></i></button></section> <section v-else-if="page === 'notices'" class="record-panel notice-list-vue"><button v-for="notice in data.notices" :key="notice.id" type="button" @click="openNotice(notice.id)"><time>{{ formatDate(notice.publishAt) }}</time><span><em>{{ notice.category }}</em><strong>{{ notice.title }}</strong><small>{{ notice.summary }}</small></span><i></i></button></section>
+2 -2
View File
@@ -6,7 +6,7 @@
<meta name="theme-color" content="#0d2d54" /> <meta name="theme-color" content="#0d2d54" />
<meta name="description" content="衡准考试信息管理系统——考试通知、考生报名、准考证与成绩查询一站式服务。" /> <meta name="description" content="衡准考试信息管理系统——考试通知、考生报名、准考证与成绩查询一站式服务。" />
<title>衡准 · 考试信息管理系统</title> <title>衡准 · 考试信息管理系统</title>
<link rel="stylesheet" href="/vue-app/app.css?v=20260723" /> <link rel="stylesheet" href="/vue-app/app.css?v=20260723-2" />
</head> </head>
<body> <body>
<div id="app" aria-live="polite"> <div id="app" aria-live="polite">
@@ -16,6 +16,6 @@
<span>系统正在加载</span> <span>系统正在加载</span>
</div> </div>
</div> </div>
<script type="module" src="/vue-app/app.js?v=20260723"></script> <script type="module" src="/vue-app/app.js?v=20260723-2"></script>
</body> </body>
</html> </html>
File diff suppressed because one or more lines are too long
@@ -26,4 +26,27 @@ public sealed class AdminNoticeServiceTests
new JsonObject { ["publicVisible"] = "false" }, new JsonObject { ["publicVisible"] = "false" },
out _)); out _));
} }
[Fact]
public void PublicationProjector_ExposesConsistentVisibilityFields()
{
var payload = new JsonObject
{
["publicVisible"] = false,
["reviewedAt"] = "2026-07-23T00:00:00.000Z"
};
var snapshot = new AdminNoticeManagementSnapshot(
[],
[new AdminPublicationRecord(
"plan_1", "plan", "exam_1", null, "school_1", "approved", payload,
"2026-07-23T00:00:00.000Z", "2026-07-23T00:00:00.000Z")],
new Dictionary<string, string> { ["exam_1"] = "统一考试" },
new Dictionary<string, string> { ["school_1"] = "第一中学" });
var publication = Assert.Single(AdminPublicationProjector.Build(snapshot));
Assert.False(publication["publicVisible"]!.GetValue<bool>());
Assert.False(publication["visible"]!.GetValue<bool>());
Assert.Equal("hidden", publication["status"]!.GetValue<string>());
}
} }