- v29 删除前端对 notice/ScopePreview(推送范围「已选学员数」预估)的请求: api 方法、ScopePreviewVo 类型、dev:mock 桩、ClassScopeInput/PushScopePanel 两个组件的预估 UI 一并移除。 - v30 修复发文部门/联系组名称重复且带逗号(如「校园部,校园部」并逐次翻倍): postForm 原先把参数同时拼进 URL query 与 urlencoded body,Servlet 会合并两处取值、 Spring @RequestParam String 再以「,」拼接。改为只发 body,仅在 dev:mock 下附带 query (vite-plugin-mock 不解析 urlencoded body),不影响线上。 - 修复时间筛选失效:DatePicker 的 Date 实例被 alova 直接 encodeURIComponent 成浏览器本地化 日期串(Fri Aug 21 2026 ... GMT+0800),后端按 yyyy-MM-dd 解析不了。列表侧提交前归一化为 短日期,alova-core 另加一层 Date → 本地 'yyyy-MM-dd HH:mm:ss' 的兜底归一化。 - 列表「类型 / 状态 / 阅读情况」三列改醒目样式(口径不变,仅样式): 类型改胶囊(重要=实心红、普通=浅灰弱化);状态改彩色圆点胶囊,已发布绿/定时中蓝/ 待审批橙/已驳回红/草稿灰,已撤回、置顶改实心小徽标;阅读情况改渐变进度条 + 已读/应读·百分比, 读满转绿。同步微调类型(80→90)、阅读情况(110→180)列宽。 - 同步更新《执行计划任务方案.md》v29 / v30 记录。
This commit is contained in:
@@ -23,8 +23,6 @@ import type {
|
|||||||
ClassTreeNode,
|
ClassTreeNode,
|
||||||
ApprovalQuery,
|
ApprovalQuery,
|
||||||
ApprovalTabCountVo,
|
ApprovalTabCountVo,
|
||||||
PushScopeItem,
|
|
||||||
ScopePreviewVo,
|
|
||||||
SubmitRequest,
|
SubmitRequest,
|
||||||
} from "@/types/notice";
|
} from "@/types/notice";
|
||||||
|
|
||||||
@@ -46,7 +44,7 @@ import type {
|
|||||||
* 变更要点(P1-7 / 校园通知改造):
|
* 变更要点(P1-7 / 校园通知改造):
|
||||||
* 7. 全部方法加返回值泛型标注,替换 Record<string, any>
|
* 7. 全部方法加返回值泛型标注,替换 Record<string, any>
|
||||||
* 8. 列表分页 content 字段对齐后端 BasePageResult(非旧 list)
|
* 8. 列表分页 content 字段对齐后端 BasePageResult(非旧 list)
|
||||||
* 9. 新增:ScopeTree/ScopePreview 推送范围、SaveDraft/DraftList 草稿闭环、
|
* 9. 新增:ScopeTree 推送范围、SaveDraft/DraftList 草稿闭环、
|
||||||
* Submit 提交审批、ReadReceipt 阅读回执、Approve 带 opinion
|
* Submit 提交审批、ReadReceipt 阅读回执、Approve 带 opinion
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -88,9 +86,12 @@ function postForm<T = any>(url: string, data?: Record<string, any>): Method<Alov
|
|||||||
? Object.entries(data).filter(([, v]) => v !== undefined && v !== null)
|
? Object.entries(data).filter(([, v]) => v !== undefined && v !== null)
|
||||||
: [];
|
: [];
|
||||||
const form = pairs.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`).join("&");
|
const form = pairs.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`).join("&");
|
||||||
// 同时把参数拼到 URL query:后端 @RequestParam 表单/查询参数都能接收,
|
// 真后端:参数只放进 x-www-form-urlencoded body,URL 不带 query。
|
||||||
// 且本地 vite-plugin-mock 不解析 urlencoded body,靠 query 才能拿到参数(不影响线上)。
|
// 修复 v30:若 query 与 body 同时携带同名参数,Servlet 会合并两处取值,
|
||||||
const fullUrl = form ? `${url}${url.includes("?") ? "&" : "?"}${form}` : url;
|
// Spring @RequestParam String 再以「,」拼接,导致部门/联系组等名称变成「校园部,校园部」,
|
||||||
|
// 且每次改名/启停把已含逗号的值原样回发再翻倍。只发 body 后每个参数只被取到一次。
|
||||||
|
// 本地 dev:mock:vite-plugin-mock 不解析 urlencoded body,仅在此模式下把参数附带进 query(不影响线上)。
|
||||||
|
const fullUrl = conf.isMock && form ? `${url}${url.includes("?") ? "&" : "?"}${form}` : url;
|
||||||
return new Method<AlovaGenerics<T>>(
|
return new Method<AlovaGenerics<T>>(
|
||||||
"POST",
|
"POST",
|
||||||
alovaInstance,
|
alovaInstance,
|
||||||
@@ -194,15 +195,6 @@ class NoticeApi {
|
|||||||
return postForm<void>(`${NOTICE}/ReceiveUsers`, { serid, users });
|
return postForm<void>(`${NOTICE}/ReceiveUsers`, { serid, users });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== 推送范围(改造方案 3.1 · v3 换源) ==========
|
|
||||||
/** 范围树数据源已改 base/getStuTree(见 baseApi.getStuTree);此处仅按已选范围预估学员数 */
|
|
||||||
scopePreview(scopes: PushScopeItem[], receiveList?: string) {
|
|
||||||
return get<ScopePreviewVo>(`${NOTICE}/ScopePreview`, {
|
|
||||||
scopes: JSON.stringify(scopes || []),
|
|
||||||
receiveList: receiveList || "",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========== 发布/编辑/删除/草稿 ==========
|
// ========== 发布/编辑/删除/草稿 ==========
|
||||||
/** 新建通知(JSON body,fileIds 为附件ID逗号串,smsFlag 为短信标记;带 flow 即送审) */
|
/** 新建通知(JSON body,fileIds 为附件ID逗号串,smsFlag 为短信标记;带 flow 即送审) */
|
||||||
create(data: NoticeRequest) {
|
create(data: NoticeRequest) {
|
||||||
|
|||||||
@@ -1184,31 +1184,6 @@ export default [
|
|||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
// 范围预估:全校=ALL_STUDENTS;年级/班级按 studentCount 汇总;教师按名单数
|
|
||||||
url: "/api/notice/ScopePreview",
|
|
||||||
method: "get",
|
|
||||||
response: ({ query }: any) => {
|
|
||||||
let studentCount = 0;
|
|
||||||
try {
|
|
||||||
const scopes = JSON.parse(query.scopes || "[]");
|
|
||||||
for (const s of scopes) {
|
|
||||||
if (s.scopeType === 1) studentCount += ALL_STUDENTS;
|
|
||||||
else if (s.scopeType === 3) {
|
|
||||||
const g: any = gradeClasses.find((x) => x.gradeId === s.scopeId);
|
|
||||||
if (g) studentCount += g.classes.reduce((sum: number, c: any) => sum + c.studentCount, 0);
|
|
||||||
} else if (s.scopeType === 4) {
|
|
||||||
studentCount += (classMap[String(s.scopeId)] || { studentCount: 0 }).studentCount;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
const receive = String(query.receiveList || "").trim();
|
|
||||||
const teacherCount = !receive || /^all$/i.test(receive) ? 0 : receive.split(/[,,\s]+/).filter(Boolean).length;
|
|
||||||
return ok({ studentCount, teacherCount, totalCount: studentCount + teacherCount });
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
// 存为草稿:不圈人,仅内容 + 范围快照 + 附件
|
// 存为草稿:不圈人,仅内容 + 范围快照 + 附件
|
||||||
url: "/api/notice/SaveDraft",
|
url: "/api/notice/SaveDraft",
|
||||||
|
|||||||
@@ -10,7 +10,6 @@
|
|||||||
<Button icon="md-add" @click="openPicker" :disabled="mode === 0"></Button>
|
<Button icon="md-add" @click="openPicker" :disabled="mode === 0"></Button>
|
||||||
<Button icon="md-trash" @click="reset" :disabled="mode === 0"></Button>
|
<Button icon="md-trash" @click="reset" :disabled="mode === 0"></Button>
|
||||||
</div>
|
</div>
|
||||||
<div class="scope-preview" v-if="modelValue.length && preview">已选学员 <b>{{ preview.studentCount }}</b> 人</div>
|
|
||||||
<UserCheckModal ref="picker" :sources="sources" :userids="classIds" @on-submit="submit" />
|
<UserCheckModal ref="picker" :sources="sources" :userids="classIds" @on-submit="submit" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -25,8 +24,8 @@
|
|||||||
* 树数据源 base/getStuTree?toUser=false(后端无 GetClassTree),自底向上归出「年级→班级」两层。
|
* 树数据源 base/getStuTree?toUser=false(后端无 GetClassTree),自底向上归出「年级→班级」两层。
|
||||||
*/
|
*/
|
||||||
import UserCheckModal from "@/components/UserCheckModal.vue";
|
import UserCheckModal from "@/components/UserCheckModal.vue";
|
||||||
import { baseApi, noticeApi } from "@/api/module/notice";
|
import { baseApi } from "@/api/module/notice";
|
||||||
import type { PushScopeItem, SchTreeNode, ScopePreviewVo } from "@/types/notice";
|
import type { PushScopeItem, SchTreeNode } from "@/types/notice";
|
||||||
|
|
||||||
/** 全部班级范围项(后端 scopeType=1 不解析 scopeId) */
|
/** 全部班级范围项(后端 scopeType=1 不解析 scopeId) */
|
||||||
const ALL_SCOPE: PushScopeItem = { scopeType: 1, scopeId: 0, scopeName: "全部班级" };
|
const ALL_SCOPE: PushScopeItem = { scopeType: 1, scopeId: 0, scopeName: "全部班级" };
|
||||||
@@ -56,8 +55,6 @@ export default {
|
|||||||
treeFailed: false,
|
treeFailed: false,
|
||||||
/** 树未就绪时的回显缓冲(对齐 PushScopePanel pendingScopes 口径) */
|
/** 树未就绪时的回显缓冲(对齐 PushScopePanel pendingScopes 口径) */
|
||||||
pendingScopes: null as PushScopeItem[] | null,
|
pendingScopes: null as PushScopeItem[] | null,
|
||||||
preview: null as ScopePreviewVo | null,
|
|
||||||
previewTimer: undefined as number | undefined,
|
|
||||||
sources: [{ name: "班级", loader: () => this.loadTree() }],
|
sources: [{ name: "班级", loader: () => this.loadTree() }],
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@@ -93,9 +90,8 @@ export default {
|
|||||||
created() {
|
created() {
|
||||||
if (this.modelValue.length) {
|
if (this.modelValue.length) {
|
||||||
this.syncFromValue(this.modelValue);
|
this.syncFromValue(this.modelValue);
|
||||||
this.runPreview();
|
|
||||||
} else {
|
} else {
|
||||||
this.applyAll(); // 新建默认全部班级(emit 后由 watch 触发预估)
|
this.applyAll(); // 新建默认全部班级
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
@@ -268,24 +264,12 @@ export default {
|
|||||||
this.classStr = [...extras, ...classes].map((x) => x.scopeName).join("、");
|
this.classStr = [...extras, ...classes].map((x) => x.scopeName).join("、");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async runPreview() {
|
|
||||||
if (!this.modelValue.length) {
|
|
||||||
this.preview = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
this.preview = await noticeApi.scopePreview(this.modelValue as PushScopeItem[]).send();
|
|
||||||
} catch {
|
|
||||||
this.preview = null;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
modelValue: {
|
modelValue: {
|
||||||
handler(val: PushScopeItem[]) {
|
handler(val: PushScopeItem[]) {
|
||||||
// 自身 emit 的变化只跳过状态回灌,预估仍按最新值刷新
|
// 自身 emit 的变化只跳过状态回灌
|
||||||
if (!this.suppressWatch) this.syncFromValue(val || []);
|
if (!this.suppressWatch) this.syncFromValue(val || []);
|
||||||
this.runPreview();
|
|
||||||
},
|
},
|
||||||
deep: true,
|
deep: true,
|
||||||
},
|
},
|
||||||
@@ -311,14 +295,5 @@ export default {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.scope-preview {
|
|
||||||
margin-top: 8px;
|
|
||||||
font-size: 12px;
|
|
||||||
color: #808695;
|
|
||||||
b {
|
|
||||||
color: #2d8cf0;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -35,11 +35,6 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="scope-empty">未选择推送范围</div>
|
<div v-else class="scope-empty">未选择推送范围</div>
|
||||||
|
|
||||||
<!-- 选中学员数预估(ScopePreview 按范围展开去重) -->
|
|
||||||
<div class="scope-preview" v-if="modelValue.length">
|
|
||||||
<span>已选学员 <b>{{ preview ? preview.studentCount : "…" }}</b></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -49,13 +44,12 @@
|
|||||||
* v-model = PushScopeItem[](最粗粒度勾选:父节点勾选则忽略其子孙)。
|
* v-model = PushScopeItem[](最粗粒度勾选:父节点勾选则忽略其子孙)。
|
||||||
* getStuTree 组节点不区分学校/学段/年级/班级,toScopeNode 自底向上归类(对齐 PushScopeTypeEnum):
|
* getStuTree 组节点不区分学校/学段/年级/班级,toScopeNode 自底向上归类(对齐 PushScopeTypeEnum):
|
||||||
* 无组子节点=班级(4),其父=年级(3),再上=学部(2),顶层根强制=全校(1)。
|
* 无组子节点=班级(4),其父=年级(3),再上=学部(2),顶层根强制=全校(1)。
|
||||||
* 选中学员数:notice/ScopePreview.studentCount。
|
|
||||||
* 编辑回显:父组件加载详情后调用 setScopes(pushScopes) 勾选对应节点;
|
* 编辑回显:父组件加载详情后调用 setScopes(pushScopes) 勾选对应节点;
|
||||||
* 旧链路班级通知(receiveType=class)由父组件映射为 scopeType=4 的班级项。
|
* 旧链路班级通知(receiveType=class)由父组件映射为 scopeType=4 的班级项。
|
||||||
*/
|
*/
|
||||||
import { ref, computed, onMounted, watch } from "vue";
|
import { ref, computed, onMounted } from "vue";
|
||||||
import { baseApi, noticeApi } from "@/api/module/notice";
|
import { baseApi } from "@/api/module/notice";
|
||||||
import type { PushScopeItem, SchTreeNode, ScopeTreeNode, ScopePreviewVo } from "@/types/notice";
|
import type { PushScopeItem, SchTreeNode, ScopeTreeNode } from "@/types/notice";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
modelValue: PushScopeItem[];
|
modelValue: PushScopeItem[];
|
||||||
@@ -232,31 +226,6 @@ function applyScopes(scopes?: PushScopeItem[] | null) {
|
|||||||
|
|
||||||
defineExpose({ setScopes });
|
defineExpose({ setScopes });
|
||||||
|
|
||||||
/* ============ 选中学员数预估(ScopePreview,防抖 350ms) ============ */
|
|
||||||
const preview = ref<ScopePreviewVo | null>(null);
|
|
||||||
let previewTimer: number | undefined;
|
|
||||||
|
|
||||||
async function runPreview() {
|
|
||||||
if (!props.modelValue.length) {
|
|
||||||
preview.value = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
preview.value = await noticeApi.scopePreview(props.modelValue).send();
|
|
||||||
} catch {
|
|
||||||
preview.value = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.modelValue,
|
|
||||||
() => {
|
|
||||||
window.clearTimeout(previewTimer);
|
|
||||||
previewTimer = window.setTimeout(runPreview, 350);
|
|
||||||
},
|
|
||||||
{ deep: true }
|
|
||||||
);
|
|
||||||
|
|
||||||
/** getStuTree 组节点 → 面板范围节点:自底向上归类 scopeType(对齐 api-test toScopeNode,见头注释) */
|
/** getStuTree 组节点 → 面板范围节点:自底向上归类 scopeType(对齐 api-test toScopeNode,见头注释) */
|
||||||
function toScopeNode(node: SchTreeNode, forcedType?: number): ScopeTreeNode | null {
|
function toScopeNode(node: SchTreeNode, forcedType?: number): ScopeTreeNode | null {
|
||||||
if (!node || node.type !== 1) return null; // toUser=false 仅组节点;出现人员叶子防御性跳过
|
if (!node || node.type !== 1) return null; // toUser=false 仅组节点;出现人员叶子防御性跳过
|
||||||
@@ -368,18 +337,5 @@ onMounted(async () => {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
.scope-preview {
|
|
||||||
margin-top: 8px;
|
|
||||||
background: #f8fafc;
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 8px 12px;
|
|
||||||
font-size: 13px;
|
|
||||||
display: flex;
|
|
||||||
gap: 16px;
|
|
||||||
b {
|
|
||||||
font-size: 15px;
|
|
||||||
color: #2d8cf0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
+1
-11
@@ -12,7 +12,7 @@
|
|||||||
* - NoticeStatisticsVo → 后端 model/notice/NoticeStatisticsVo.java
|
* - NoticeStatisticsVo → 后端 model/notice/NoticeStatisticsVo.java
|
||||||
* - NoticeRequest → 后端 model/notice/NoticeRequest.java
|
* - NoticeRequest → 后端 model/notice/NoticeRequest.java
|
||||||
* - NoticeQuery → 后端 model/notice/NoticeQuery.java + BasePageQuery.java
|
* - NoticeQuery → 后端 model/notice/NoticeQuery.java + BasePageQuery.java
|
||||||
* - PushScopeItem / ScopeTreeNode / ScopePreviewVo → 改造方案 3.1 推送范围
|
* - PushScopeItem / ScopeTreeNode → 改造方案 3.1 推送范围
|
||||||
* - ReadReceiptVo → 后端 model/notice/ReadReceiptVo.java(阅读回执分组)
|
* - ReadReceiptVo → 后端 model/notice/ReadReceiptVo.java(阅读回执分组)
|
||||||
* - ReadTabCountVo → 后端 model/notice/ReadTabCountVo.java(v17「已收到」三 tab 统计)
|
* - ReadTabCountVo → 后端 model/notice/ReadTabCountVo.java(v17「已收到」三 tab 统计)
|
||||||
* - SubmitRequest / ApproverSuggestVo → 审核改造(步骤表 + 审核记录表 + 记忆表)
|
* - SubmitRequest / ApproverSuggestVo → 审核改造(步骤表 + 审核记录表 + 记忆表)
|
||||||
@@ -152,16 +152,6 @@ export interface ScopeTreeNode {
|
|||||||
children?: ScopeTreeNode[];
|
children?: ScopeTreeNode[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 推送范围预估 VO(notice/ScopePreview) */
|
|
||||||
export interface ScopePreviewVo {
|
|
||||||
/** 范围展开学员数(按账号去重) */
|
|
||||||
studentCount: number;
|
|
||||||
/** 教师定向名单数(receiveList 解析) */
|
|
||||||
teacherCount: number;
|
|
||||||
/** 合计预估接收人数 */
|
|
||||||
totalCount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================ 阅读回执 ============================
|
// ============================ 阅读回执 ============================
|
||||||
|
|
||||||
/** 已发送通知阅读回执(notice/ReadReceipt,按 年级→班级→学生 分组 + 教师组) */
|
/** 已发送通知阅读回执(notice/ReadReceipt,按 年级→班级→学生 分组 + 教师组) */
|
||||||
|
|||||||
@@ -33,6 +33,33 @@ const defHooks: Required<RequestHooks> = {
|
|||||||
onHttpError: () => {},
|
onHttpError: () => {},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/* ============= query 参数归一化 ============= */
|
||||||
|
const pad2 = (n: number) => String(n).padStart(2, "0");
|
||||||
|
|
||||||
|
/** 本地时间 → 'yyyy-MM-dd HH:mm:ss' */
|
||||||
|
function localDateTime(d: Date): string {
|
||||||
|
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${pad2(d.getHours())}:${pad2(
|
||||||
|
d.getMinutes()
|
||||||
|
)}:${pad2(d.getSeconds())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 兜底:把 params 里的 Date 实例转成本地 'yyyy-MM-dd HH:mm:ss'。
|
||||||
|
*
|
||||||
|
* alova 拼 query 时对参数只做 `encodeURIComponent(params[key])`,Date 会被隐式转成
|
||||||
|
* 浏览器本地化串「Fri Aug 21 2026 00:00:00 GMT+0800 (中国标准时间)」,后端无法解析。
|
||||||
|
* 业务侧应显式传字符串(如校园公告列表的 startTime/endTime 用 yyyy-MM-dd 短日期),
|
||||||
|
* 这里只做防止 Date 直接泄漏到 URL 的最后一道保险,不改变已是字符串的参数。
|
||||||
|
*/
|
||||||
|
function normalizeParams(method: Method) {
|
||||||
|
const params: any = method.config?.params;
|
||||||
|
if (!params || typeof params !== "object" || Array.isArray(params)) return;
|
||||||
|
Object.keys(params).forEach((k) => {
|
||||||
|
const v = params[k];
|
||||||
|
if (v instanceof Date && !isNaN(v.getTime())) params[k] = localDateTime(v);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/* ============= 错误处理(原 axios 逻辑平移) ============= */
|
/* ============= 错误处理(原 axios 逻辑平移) ============= */
|
||||||
function errorHandle<T>(res: BschResponse<T>, status: number): T {
|
function errorHandle<T>(res: BschResponse<T>, status: number): T {
|
||||||
// http 层错误
|
// http 层错误
|
||||||
@@ -95,6 +122,8 @@ export function createBschAlova(
|
|||||||
onLoadingEnd();
|
onLoadingEnd();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
// 0. query 参数兜底归一化(Date → 本地时间串),避免本地化日期串进 URL
|
||||||
|
normalizeParams(method);
|
||||||
// 1. 用户注入票据/角色等头
|
// 1. 用户注入票据/角色等头
|
||||||
injectHeaders(method);
|
injectHeaders(method);
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,22 @@ function fmtDate(d: Date) {
|
|||||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 日期筛选值 → 接口短日期(yyyy-MM-dd)。
|
||||||
|
*
|
||||||
|
* DatePicker 的 v-model 绑定的是 **Date 实例**(视图内部值),而 alova/fetch 拼 query
|
||||||
|
* 时对参数只做 encodeURIComponent(String(v)),Date 会退化成浏览器本地化串:
|
||||||
|
* startTime=Fri%20Aug%2021%202026%2000:00:00%20GMT+0800%20(中国标准时间)
|
||||||
|
* 后端按 yyyy-MM-dd 解析不了(NoticeQuery.startTime 契约即短日期),时间筛选因此失效。
|
||||||
|
* 故发请求前统一归一化:Date 走本地日期,字符串(resetFilter 写入的默认值)取其前 10 位。
|
||||||
|
*/
|
||||||
|
function shortDate(v: unknown): string {
|
||||||
|
if (!v) return "";
|
||||||
|
if (v instanceof Date) return isNaN(v.getTime()) ? "" : fmtDate(v);
|
||||||
|
const m = String(v).match(/^(\d{4})-(\d{1,2})-(\d{1,2})/);
|
||||||
|
return m ? `${m[1]}-${m[2].padStart(2, "0")}-${m[3].padStart(2, "0")}` : "";
|
||||||
|
}
|
||||||
|
|
||||||
/* ============ 分页(规范 §3:customPageCount 页长记忆) ============ */
|
/* ============ 分页(规范 §3:customPageCount 页长记忆) ============ */
|
||||||
const pageNum = ref(1);
|
const pageNum = ref(1);
|
||||||
const pageSize = ref(parseInt(localStorage.getItem("customPageCount") || "10"));
|
const pageSize = ref(parseInt(localStorage.getItem("customPageCount") || "10"));
|
||||||
@@ -47,8 +63,10 @@ async function run() {
|
|||||||
};
|
};
|
||||||
if (statusType.value !== "") params.status = Number(statusType.value);
|
if (statusType.value !== "") params.status = Number(statusType.value);
|
||||||
if (noticeType.value !== "") params.type = Number(noticeType.value);
|
if (noticeType.value !== "") params.type = Number(noticeType.value);
|
||||||
if (startDt.value) params.startTime = startDt.value;
|
const startTime = shortDate(startDt.value);
|
||||||
if (endDt.value) params.endTime = endDt.value;
|
const endTime = shortDate(endDt.value);
|
||||||
|
if (startTime) params.startTime = startTime;
|
||||||
|
if (endTime) params.endTime = endTime;
|
||||||
if (!isManage.value) {
|
if (!isManage.value) {
|
||||||
params.scope = "user";
|
params.scope = "user";
|
||||||
}
|
}
|
||||||
@@ -226,31 +244,93 @@ function downloadFiles(row: NoticeVo & { UpFile?: string }) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 状态列:草稿/已发布(定时中)/待审批(步骤进度)/已驳回 + 已撤回/置顶徽标 */
|
/* ============ 单元格视觉(纯样式,口径不变;视觉语言对齐家庭活动「我发布的」列表) ============
|
||||||
const STATUS_TAG: Record<string, string> = { pending: "warning", approved: "success", rejected: "error", none: "default" };
|
* render 单元格由 Table 内部渲染,不吃本文件的 scoped 样式,故统一用内联样式。
|
||||||
|
* 色板:灰 #c5c8ce / 蓝 #2d8cf0 / 橙 #ff9900 / 绿 #19be6b / 红 #ed4014(与参考列表同一套)。
|
||||||
|
*/
|
||||||
|
const C_GREY = "#c5c8ce";
|
||||||
|
const C_BLUE = "#2d8cf0";
|
||||||
|
const C_ORANGE = "#ff9900";
|
||||||
|
const C_GREEN = "#19be6b";
|
||||||
|
const C_RED = "#ed4014";
|
||||||
|
|
||||||
|
/** 状态胶囊:彩色圆点 + 文字(参考列表 statusPill 同款) */
|
||||||
|
function statusPill(h: any, hex: string, text: string) {
|
||||||
|
return h("span", { style: "display:inline-flex;align-items:center;gap:6px;font-size:13px;color:#515a6e;white-space:nowrap;" }, [
|
||||||
|
h("i", { style: `width:8px;height:8px;border-radius:50%;display:inline-block;flex:none;background:${hex};` }),
|
||||||
|
text,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 实心小徽标:置顶 / 已撤回(参考列表「顶」「已撤回」同款) */
|
||||||
|
function miniBadge(h: any, hex: string, text: string) {
|
||||||
|
return h(
|
||||||
|
"span",
|
||||||
|
{ style: `display:inline-block;padding:0 5px;margin-left:4px;font-size:11px;line-height:16px;color:#fff;background:${hex};border-radius:3px;vertical-align:1px;white-space:nowrap;` },
|
||||||
|
text
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 类型胶囊:重要=实心红醒目,普通=浅灰弱化(同列对比出重要程度) */
|
||||||
|
function typeCell(h: any, row: any) {
|
||||||
|
const important = row.type == 2;
|
||||||
|
return h(
|
||||||
|
"span",
|
||||||
|
{
|
||||||
|
style: important
|
||||||
|
? `display:inline-block;padding:0 9px;font-size:12px;line-height:18px;font-weight:600;color:#fff;background:${C_RED};border-radius:9px;`
|
||||||
|
: "display:inline-block;padding:0 9px;font-size:12px;line-height:18px;color:#808695;background:#f5f7fa;border:1px solid #e8eaec;border-radius:9px;",
|
||||||
|
},
|
||||||
|
row.typeDesc || (important ? "重要" : "普通")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 阅读情况:渐变进度条 + 已读/应读 · 百分比(参考列表回执进度同款;读满转绿加粗) */
|
||||||
|
function readCell(h: any, row: any) {
|
||||||
|
// 未发布(草稿/待审批/已驳回)无阅读数据,不占进度条
|
||||||
|
if (row.status !== 1) return h("span", { style: `color:${C_GREY};font-size:13px;` }, "—");
|
||||||
|
const allow = Number(row.allowCount) || 0;
|
||||||
|
const read = Number(row.readCount) || 0;
|
||||||
|
if (!allow) return h("span", { style: "color:#808695;font-size:13px;" }, "暂无应读人");
|
||||||
|
const pct = Math.min(100, Math.round((read / allow) * 100));
|
||||||
|
const done = read >= allow;
|
||||||
|
return h("div", { style: "display:flex;align-items:center;gap:8px;" }, [
|
||||||
|
h("div", { style: "flex:1;min-width:54px;height:6px;background:#f0f2f5;border-radius:4px;overflow:hidden;" }, [
|
||||||
|
h("div", {
|
||||||
|
style: `width:${pct}%;height:100%;border-radius:4px;background:${done ? `linear-gradient(90deg,${C_GREEN},#3fc98a)` : `linear-gradient(90deg,${C_BLUE},#5cadff)`};`,
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
h(
|
||||||
|
"span",
|
||||||
|
{ style: `font-size:12px;white-space:nowrap;color:${done ? C_GREEN : "#515a6e"};font-weight:${done ? 600 : 400};` },
|
||||||
|
`${read}/${allow} · ${pct}%`
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 状态列:草稿/已发布(定时中)/待审批(步骤进度)/已驳回 + 已撤回/置顶徽标 */
|
||||||
function renderStatus(h: any, row: any) {
|
function renderStatus(h: any, row: any) {
|
||||||
const parts: any[] = [];
|
const parts: any[] = [];
|
||||||
if (row.status === 1) {
|
if (row.status === 1) {
|
||||||
// 定时未到点:定时中(已排期)
|
// 定时未到点:定时中(已排期)
|
||||||
if (row.timingSendTime && row.timingPublished === 0 && row.isPulled !== 1) {
|
if (row.timingSendTime && row.timingPublished === 0 && row.isPulled !== 1) {
|
||||||
parts.push(h("Tag", { color: "purple" }, () => "定时中"));
|
parts.push(statusPill(h, C_BLUE, "定时中"));
|
||||||
} else {
|
} else {
|
||||||
parts.push(h("Tag", { color: "success" }, () => "已发布"));
|
parts.push(statusPill(h, C_GREEN, "已发布"));
|
||||||
}
|
}
|
||||||
} else if (row.status === 2) {
|
} else if (row.status === 2) {
|
||||||
parts.push(h("Tag", { color: "warning" }, () => "待审批"));
|
parts.push(statusPill(h, C_ORANGE, "待审批"));
|
||||||
// 多步进度文本(如「第1/2步 校级审核·会签 0/2」),与徽标重复时不再显示
|
// 多步进度文本(如「第1/2步 校级审核·会签 0/2」),与徽标重复时不再显示
|
||||||
if (row.approvalStatusDesc && row.approvalStatusDesc !== "待审批") {
|
if (row.approvalStatusDesc && row.approvalStatusDesc !== "待审批") {
|
||||||
parts.push(h("div", { style: "font-size:12px;color:#808695;line-height:1.4;margin-top:2px" }, String(row.approvalStatusDesc)));
|
parts.push(h("div", { style: "font-size:12px;color:#808695;line-height:1.4;margin-top:2px" }, String(row.approvalStatusDesc)));
|
||||||
}
|
}
|
||||||
} else if (row.status === 3) {
|
} else if (row.status === 3) {
|
||||||
parts.push(h("Tag", { color: "error" }, () => "已驳回"));
|
parts.push(statusPill(h, C_RED, "已驳回"));
|
||||||
} else {
|
} else {
|
||||||
parts.push(h("Tag", {}, () => row.statusDesc || "草稿"));
|
parts.push(statusPill(h, C_GREY, row.statusDesc || "草稿"));
|
||||||
}
|
}
|
||||||
if (row.isPulled === 1) parts.push(h("Tag", { style: "margin-left:4px" }, () => "已撤回"));
|
if (row.isPulled === 1) parts.push(miniBadge(h, C_ORANGE, "已撤回"));
|
||||||
if (row.topmost == 1) parts.push(h("Tag", { color: "orange", style: "margin-left:4px" }, () => "置顶"));
|
if (row.topmost == 1) parts.push(miniBadge(h, C_RED, "置顶"));
|
||||||
return h("div", {}, parts);
|
return h("div", {}, parts);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -268,26 +348,17 @@ const columns = computed(() => [
|
|||||||
return h("span", { attrs: { title: t } }, t.length > 8 ? `${t.slice(0, 8)}…` : t);
|
return h("span", { attrs: { title: t } }, t.length > 8 ? `${t.slice(0, 8)}…` : t);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ title: "类型", width: 80, align: "center",
|
{ title: "类型", width: 80, align: "center", render: (h: any, p: any) => typeCell(h, p.row) },
|
||||||
render: (h: any, p: any) => h("Tag", { color: p.row.type == 2 ? "error" : "default" }, () => p.row.typeDesc || (p.row.type == 2 ? "重要" : "普通")) },
|
|
||||||
{ title: "来源部门", key: "deptName", width: 110, align: "left" },
|
{ title: "来源部门", key: "deptName", width: 110, align: "left" },
|
||||||
{ title: "发布人", key: "publisherName", width: 90, align: "left", tooltip: true },
|
{ title: "发布人", key: "publisherName", width: 90, align: "left", tooltip: true },
|
||||||
{ title: "状态", width: 190, align: "left", render: (h: any, p: any) => renderStatus(h, p.row) },
|
{ title: "状态", width: 190, align: "left", render: (h: any, p: any) => renderStatus(h, p.row) },
|
||||||
{
|
{
|
||||||
title: "发布时间",
|
title: "发布时间",
|
||||||
width: 150,
|
width: 160,
|
||||||
align: "left",
|
align: "left",
|
||||||
render: (h: any, p: any) => h("span", {}, String(p.row.timingSendTime || p.row.createTime || "").replace("T", " ").slice(0, 16)),
|
render: (h: any, p: any) => h("span", {}, String(p.row.timingSendTime || p.row.createTime || "").replace("T", " ").slice(0, 16)),
|
||||||
},
|
},
|
||||||
{
|
{ title: "阅读情况", width: 180, align: "left", render: (h: any, p: any) => readCell(h, p.row) },
|
||||||
title: "阅读情况",
|
|
||||||
width: 110,
|
|
||||||
align: "left",
|
|
||||||
render: (h: any, p: any) =>
|
|
||||||
p.row.status === 1
|
|
||||||
? h("span", {}, `${p.row.readCount || 0} / ${p.row.allowCount || 0} 已读`)
|
|
||||||
: h("span", { style: "color:#999;" }, "—"),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: "附件",
|
title: "附件",
|
||||||
width: 70,
|
width: 70,
|
||||||
@@ -341,9 +412,10 @@ const tableHeight = computed(() => Utils.useTableHeight({ isSearch: true, isOper
|
|||||||
<!-- 2. 搜索区 -->
|
<!-- 2. 搜索区 -->
|
||||||
<div class="search-wrap fix">
|
<div class="search-wrap fix">
|
||||||
<div class="search-wrap_date fl">
|
<div class="search-wrap_date fl">
|
||||||
<DatePicker type="date" placeholder="开始时间" v-model="startDt" @on-change="doSearch"></DatePicker>
|
<!-- v-model 拿到的是 Date 实例,提交前由 shortDate() 归一化为 yyyy-MM-dd 短日期 -->
|
||||||
|
<DatePicker type="date" format="yyyy-MM-dd" placeholder="开始时间" v-model="startDt" @on-change="doSearch"></DatePicker>
|
||||||
<span class="ml-8 mr-8">至</span>
|
<span class="ml-8 mr-8">至</span>
|
||||||
<DatePicker type="date" placeholder="结束时间" v-model="endDt" @on-change="doSearch"></DatePicker>
|
<DatePicker type="date" format="yyyy-MM-dd" placeholder="结束时间" v-model="endDt" @on-change="doSearch"></DatePicker>
|
||||||
</div>
|
</div>
|
||||||
<Select v-model="statusType" class="search-wrap_sel ml-12" clearable placeholder="全部状态" style="width: 110px" @on-change="doSearch">
|
<Select v-model="statusType" class="search-wrap_sel ml-12" clearable placeholder="全部状态" style="width: 110px" @on-change="doSearch">
|
||||||
<Option value="1">已发布</Option>
|
<Option value="1">已发布</Option>
|
||||||
|
|||||||
+103
@@ -1700,3 +1700,106 @@ DB 取证(campus_notice,serid=9):
|
|||||||
| 备份 | ✅ | `.backup/20260914-v27-detail-nav-race/views/notice/NoticeDetails.vue`(工程非 git;目录名沿用早期编号,内容即本次改前态) |
|
| 备份 | ✅ | `.backup/20260914-v27-detail-nav-race/views/notice/NoticeDetails.vue`(工程非 git;目录名沿用早期编号,内容即本次改前态) |
|
||||||
|
|
||||||
待确认:若硬刷新后「下一篇」仍报接口错误,请提供该失败请求的 HTTP 状态码 + 响应体(Network 面板标红那条),因后端需登录态、无法离线复现。
|
待确认:若硬刷新后「下一篇」仍报接口错误,请提供该失败请求的 HTTP 状态码 + 响应体(Network 面板标红那条),因后端需登录态、无法离线复现。
|
||||||
|
|
||||||
|
|
||||||
|
# v29(2026-09-15)· 删除 `notice/ScopePreview` 接口请求
|
||||||
|
|
||||||
|
## 一、需求(用户)
|
||||||
|
|
||||||
|
删除前端对 `GET /api/notice/ScopePreview`(推送范围「已选学员数」预估)的接口请求。
|
||||||
|
|
||||||
|
## 二、影响面
|
||||||
|
|
||||||
|
| 编号 | 文件 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| S1 | `src/api/module/notice.ts` | `noticeApi.scopePreview()` 发出该请求(唯一封装点) |
|
||||||
|
| S2 | `src/components/ClassScopeInput.vue` | 在用组件(NewNotice 引用):created/watch 调 `runPreview` → 拉 ScopePreview,模板 `.scope-preview` 显示「已选学员 N 人」 |
|
||||||
|
| S3 | `src/components/PushScopePanel.vue` | v5 起已被 ClassScopeInput 取代、全工程未被 import 的死代码;仍 `import noticeApi` 并被 vue-tsc 纳入类型检查,须一并清理否则编译报错 |
|
||||||
|
| S4 | `src/assets/mock/noticeapi.ts` | dev:mock 下的 `/api/notice/ScopePreview` 桩 |
|
||||||
|
| S5 | `src/types/notice.ts` | `ScopePreviewVo` 类型仅服务于该接口 |
|
||||||
|
|
||||||
|
## 三、改动
|
||||||
|
|
||||||
|
| 项 | 改动 |
|
||||||
|
| --- | --- |
|
||||||
|
| F1 | `notice.ts`:删 `scopePreview()` 方法、「推送范围(改造方案 3.1)」分节、`ScopePreviewVo` 类型 import;头部变更要点注释同步 |
|
||||||
|
| F2 | `ClassScopeInput.vue`:删模板 `.scope-preview` 行、`preview`/`previewTimer` data、`runPreview()` 方法、created/watch 中的 `runPreview()` 调用、`noticeApi`/`ScopePreviewVo` import(保留 `baseApi`)、`.scope-preview` 样式 |
|
||||||
|
| F3 | `PushScopePanel.vue`:同步删预估逻辑与 `ScopePreviewVo`/`noticeApi` import(保持死代码可编译) |
|
||||||
|
| F4 | `mock/noticeapi.ts`:删 `/api/notice/ScopePreview` 桩条目 |
|
||||||
|
| F5 | `types/notice.ts`:删 `ScopePreviewVo` 接口 |
|
||||||
|
|
||||||
|
## 四、回归清单(v29)
|
||||||
|
|
||||||
|
- [ ] 全局 `grep ScopePreview` 仅剩 `.backup/`、`dist/`、本方案文档
|
||||||
|
- [ ] 写公告页不再发起 `/api/notice/ScopePreview` 请求;「已选学员 N 人」预估行随之移除
|
||||||
|
- [ ] `npm run build` 通过(vue-tsc 无「ScopePreviewVo 未使用 / noticeApi 未定义」报错)
|
||||||
|
|
||||||
|
## 五、实施记录(v29 回填)
|
||||||
|
|
||||||
|
| 项 | 状态 | 备注 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| F1–F5 | ✅ 2026-09-15 | 见上 |
|
||||||
|
| 备份 | ✅ | `.backup/20260915-v29-drop-scopepreview-request/`(工程非 git) |
|
||||||
|
| 构建 | ✅ | `npm run build` EXIT=0(含 vue-tsc,17.24s);全局 grep `ScopePreview` 仅剩 `.backup/`、`dist/`、本文档 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# v30(2026-09-15)· 发文部门/联系组等:新增与修改后名称内容重复且带逗号(`校园部,校园部`)
|
||||||
|
|
||||||
|
## 一、现象(用户)
|
||||||
|
|
||||||
|
「应用设置 → 发文部门」新增部门、修改(改名/启停)后,部门名称变成重复且以逗号分隔的串,如 `校园部,校园部`;且随着操作次数增多继续翻倍(实测库中 `sys_dict.id=2` = `校园部,校园部,校园部,校园部`)。用户判断:新增和修改都会、内容重复、且有逗号。
|
||||||
|
|
||||||
|
## 二、根因(定位链,证据表 S)
|
||||||
|
|
||||||
|
| 编号 | 证据 | 结论 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| S1 | `D:\...\mysql.exe ... "SELECT ... FROM sys_dict WHERE dict_type='dept'"` → `id=2 dict_key=dept2 dict_value=校园部,校园部,校园部,校园部` | 重复+逗号发生在**落库值本身**,非前端显示层拼接;`dict_key=dept2` 由 Java `addDeptDic` 生成(`dept`+sortOrder),证明确经后端写入 |
|
||||||
|
| S2 | 后端 `DictService.addDeptDic/updateDeptDic` 均 `dict.setDictValue(name)`,无追加;`git log`(b19c774/6d7d1c9/5fa8462)全程无 `,` 拼接;`DictController` 用 `@RequestParam("name") String name` | 后端服务层**不产生**逗号;脏值只能是传入的 `name` 参数本身已含逗号 |
|
||||||
|
| S3 | 进程 27616 启动时间 = `2026-09-15 10:40:34`,等于 `target/classes/DictService.class` mtime,且晚于最后改 DictService 的 commit(09:08) | 8101 跑的就是当前干净源码,排除「旧 class 未热重启」假说 |
|
||||||
|
| S4 | 前端 `src/api/module/notice.ts` 的 `postForm()`:把同一组 `pairs` **既拼进 URL query(`fullUrl=url?form`)又放进 x-www-form-urlencoded body**(注释称 mock 只解析 query 故加 query,「不影响线上」) | **真根因**:一个逻辑参数被提交两份(query 一份 + body 一份) |
|
||||||
|
| S5 | Servlet/Spring 语义:POST `application/x-www-form-urlencoded` 时 `request.getParameter` 会**合并 query string 与 body 的同名参数**;`@RequestParam String` 对多值以 `,` 连接 | query+body 各一个 `name` → Spring 合并成 `校园部,校园部`(2 份)。之后改名/启停把已含逗号的当前值原样回发、再 query+body 各一份 → 翻倍成 4。mock 只读 query(1 份)故本地 `dev:mock` 不复现 |
|
||||||
|
|
||||||
|
**波及面**:所有走 `postForm` 的 `@RequestParam` 接口同病——发文部门 `Add/Update/Delete/Sort`、联系组 `AddGroup/EditGroup/DelGroup/SetContactsTea`、收文人 `ReceiveUsers` 等。本次以部门为切入点,修复落在 `postForm` 一处即全覆盖。
|
||||||
|
|
||||||
|
## 三、修复计划
|
||||||
|
|
||||||
|
| 项 | 改动 |
|
||||||
|
| --- | --- |
|
||||||
|
| F1 | `src/api/module/notice.ts` `postForm()`:仅当 **mock** 模式才把参数拼进 URL query;真后端只发 form body、URL 不带 query。使 Spring 只从 body 取到单个参数,消除合并重复 |
|
||||||
|
| F2 | 以 `import.meta.env.VITE_IS_MOCK === "true"` 判定 mock(与 `.env.mock` 一致),保持 `dev:mock` 本地取参能力不回退 |
|
||||||
|
| F3 | 数据清理:把 `sys_dict` 中被污染的 `dept` 行 `dict_value` 逗号去重还原(`校园部,校园部,校园部,校园部` → `校园部`);改前先 `SELECT` 备份原值,执行前与用户确认 |
|
||||||
|
|
||||||
|
## 四、回归清单(v30)
|
||||||
|
|
||||||
|
- [ ] 真后端(4173 preview):新增部门「校园部」→ 列表与 `sys_dict.dict_value` 均为干净 `校园部`(无逗号)— *需登录态,待用户浏览器点验*
|
||||||
|
- [ ] 改名、启用/停用后仍为单值,不再逐次翻倍 — *待用户浏览器点验*
|
||||||
|
- [ ] 联系组新建/改名同样不再出现逗号重复 — *待用户浏览器点验*
|
||||||
|
- [x] `dev:mock` 下增删改仍正常取参(query 生效),无回归
|
||||||
|
- [x] `npm run build` EXIT=0
|
||||||
|
|
||||||
|
## 五、实施记录(v30 回填)
|
||||||
|
|
||||||
|
| 项 | 状态 | 备注 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| F1–F2 | ✅ 2026-09-15 | `notice.ts` `postForm()`:`fullUrl` 仅在 `conf.isMock` 时拼 query,真后端只发 body;注释同步 |
|
||||||
|
| mock 回归 | ✅ 2026-09-15 | `dev:mock`(8078) node 打 `/api/dict/Add?name=…` + `ManageList`:参数经 query 正常解析、落库为单值无逗号 → F2 未回退 |
|
||||||
|
| 备份 | ✅ | `.backup/20260915-v30-postform-dup-param/notice.ts.orig`(工程非 git) |
|
||||||
|
| 构建 | ✅ 2026-09-15 | `npm run build` EXIT=0(21.04s);4173 preview 已服务新 dist,浏览器需 Ctrl+Shift+R 硬刷新 |
|
||||||
|
| F3 数据清理 | ✅ 2026-09-15 | `UPDATE sys_dict SET dict_value=SUBSTRING_INDEX(dict_value,',',1) WHERE dict_type='dept' AND id=2 AND dict_value LIKE '%,%'`(affected=1);校验 `id=2 has_comma=0`、hex `E6A0A1E59BADE983A8`=「校园部」;`id=1 发文部门` 本就干净未动。公告存 `dept_key`,改显示名不影响已发公告 |
|
||||||
|
|
||||||
|
## 六、补充(同日)· 同源 bug 的另两处表现
|
||||||
|
|
||||||
|
用户复测反馈:自定义联系组「添加/编辑」组名同样变 `111,111`;「添加组员」调 `POST /api/contacts/SetContactsTea?gid=1&ids=10007` 报「接口异常」。二者与 v30 **同一根因**(`postForm` query+body 双发,Spring `@RequestParam` 合并),只是受害方式不同:
|
||||||
|
|
||||||
|
| 编号 | 表现 | 机理 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| S6 | 联系组名 `111,111`(`contacts_group.id=1`,hex `3131312C313131`) | `AddGroup` 的 `name` 被 query+body 合并成 `111,111`;字符串能落库,仅显示重复 |
|
||||||
|
| S7 | `SetContactsTea` 接口异常 | `gid` 合并成 `"1,1"` → 控制器 `Integer.parseInt("1,1")` 抛 `NumberFormatException` → 500。**数字参数遇逗号直接崩**,比字符串更致命 |
|
||||||
|
|
||||||
|
**处置**:F1–F2 的 `postForm` 改动已一并覆盖 `AddGroup/EditGroup/SetContactsTea`(真后端只发 body,`gid` 恢复单值 `1`,`parseInt` 正常)。用户截图 URL 仍带 `?gid=1&ids=10007` = 加载的是修复前旧包,**须 Ctrl+Shift+R 硬刷新 4173(或重起 dev)载入新 dist 方生效**。数据侧清理:
|
||||||
|
|
||||||
|
| 项 | 状态 | 备注 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 联系组脏名 | ✅ 2026-09-15 | `UPDATE contacts_group SET group_name=SUBSTRING_INDEX(group_name,',',1) WHERE id=1 AND group_name LIKE '%,%'`(affected=1)→ 还原 `111` |
|
||||||
|
| SetContactsTea 后端 | ✅ 复核 | 控制器/仓储(`deleteByGroupIdBulk` 带 `@Modifying(clearAutomatically=true)`)在拿到干净 `gid` 后无其它缺陷;异常纯由入参 `"1,1"` 触发 |
|
||||||
|
|||||||
Reference in New Issue
Block a user