Bladeren bron

refactor(ui): 操作页控件优化 P0+P1(少选项改 el-segmented + 金额/持证改输入框 + 排班页交互防错)

Q 1 uur geleden
bovenliggende
commit
9cc03368a9

+ 15 - 6
src/api/academic/course.ts

@@ -79,8 +79,11 @@ export interface CourseClassDTO {
   campusId: number;
 }
 
-/** 排班规则分页列表(端点 46,GET /api/admin/courses/:id/classes)。 */
-export function listClasses(courseId: number, params: { page?: number; pageSize?: number }): Promise<PageR<CourseClassDTO>> {
+/** 排班规则分页列表(端点 46,GET /api/admin/courses/:id/classes;作用域参数经 query 注入)。 */
+export function listClasses(
+  courseId: number,
+  params: { institutionId: number; campusId?: number; page?: number; pageSize?: number },
+): Promise<PageR<CourseClassDTO>> {
   return http.get<PageR<CourseClassDTO>>(`/api/admin/courses/${courseId}/classes`, { params }).then((r) => r.data);
 }
 
@@ -137,8 +140,11 @@ export type RecordQuery = {
   pageSize?: number;
 };
 
-/** 课表(排班实例)分页列表(端点 48,GET /api/admin/courses/:id/records)。 */
-export function listRecords(courseId: number, params: RecordQuery): Promise<PageR<CourseRecordDTO>> {
+/** 课表(排班实例)分页列表(端点 48,GET /api/admin/courses/:id/records;作用域参数经 query 注入)。 */
+export function listRecords(
+  courseId: number,
+  params: RecordQuery & { institutionId: number; campusId?: number },
+): Promise<PageR<CourseRecordDTO>> {
   return http.get<PageR<CourseRecordDTO>>(`/api/admin/courses/${courseId}/records`, { params }).then((r) => r.data);
 }
 
@@ -276,8 +282,11 @@ export interface CourseStudentDTO {
   status: number;
 }
 
-/** 报课学生分页列表(端点 53,GET /api/admin/courses/:id/students)。 */
-export function listCourseStudents(courseId: number, params: { page?: number; pageSize?: number }): Promise<PageR<CourseStudentDTO>> {
+/** 报课学生分页列表(端点 53,GET /api/admin/courses/:id/students;作用域参数经 query 注入)。 */
+export function listCourseStudents(
+  courseId: number,
+  params: { institutionId: number; campusId?: number; page?: number; pageSize?: number },
+): Promise<PageR<CourseStudentDTO>> {
   return http.get<PageR<CourseStudentDTO>>(`/api/admin/courses/${courseId}/students`, { params }).then((r) => r.data);
 }
 

+ 16 - 0
src/utils/format.ts

@@ -48,6 +48,22 @@ export function formatDateTime(iso: string): string {
   return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
 }
 
+/**
+ * 金额格式化(**不带千分位**,纯两位小数;用于 API 提交 / 后端契约字符串)。
+ *
+ * 入参:
+ *   - value number:原始金额(元)。
+ *
+ * 出参:
+ *   - string:如 "1234.50"(无逗号,后端 toCents 可直解)。
+ *
+ * 注意:展示位用 formatMoney(带千分位),**提交位用本函数**——两者不可混用。
+ */
+export function moneyPlain(value: number): string {
+  const n = Number.isFinite(value) ? value : 0;
+  return n.toFixed(2);
+}
+
 /**
  * 状态码 → 中文文案(后端 status 字符串枚举 "active" 启用 / "disabled" 停用,org 域 VARCHAR(16))。
  *

+ 15 - 12
src/views/academic/activity/index.vue

@@ -23,7 +23,7 @@ import ListPage from '@/components/ListPage.vue';
 import type { ListColumn } from '@/components/list-column';
 import { useListPage } from '@/composables/use-list-page';
 import { useScope } from '@/store/scope';
-import { formatDateTime } from '@/utils/format';
+import { formatDateTime, moneyPlain } from '@/utils/format';
 
 const scope = useScope();
 
@@ -50,9 +50,11 @@ onMounted(async () => {
 });
 
 /* ================= ① 活动列表(端点 22) ================= */
-const filters = reactive<{ keyword: string; type: number | undefined }>({ keyword: '', type: undefined });
+/** 类型筛选:'all' 哨兵(el-segmented);fetcher 剥离为 undefined */
+const filters = reactive<{ keyword: string; type: string }>({ keyword: '', type: 'all' });
 const { rows: activities, total, page, pageSize, loading, error, fetchList } = useListPage<ActivityDTO>({
-  fetcher: (p, ps) => listActivities({ ...scope.toQuery(), ...filters, page: p, pageSize: ps }),
+  fetcher: (p, ps) =>
+    listActivities({ ...scope.toQuery(), keyword: filters.keyword, type: filters.type === 'all' ? undefined : Number(filters.type), page: p, pageSize: ps }),
 });
 const typeText: Record<number, string> = { 2: '老带新', 3: '充值赠送', 4: '特殊费用' };
 const statusText: Record<number, string> = { 0: '停用', 1: '启用' };
@@ -97,15 +99,15 @@ async function loadSigneds(): Promise<void> {
 // 选中活动变化 → 重新拉报名明细
 watch(selectedActivityId, loadSigneds);
 
-/** 新增报名对话框(端点 26) */
+/** 新增报名对话框(端点 26);money 本地用 number(el-input-number),提交时 moneyPlain 转字符串 */
 const signVisible = ref(false);
 const signBusy = ref(false);
-const signForm = reactive({ userId: '', money: '', recommendUserId: '' });
+const signForm = reactive({ userId: '', money: null as number | null, recommendUserId: '' });
 function openSign(): void {
   if (!selectedActivity.value) return;
   signForm.userId = studentOpts.value[0]?.userId ?? '';
-  // 默认金额取活动金额(活动级 money);可改
-  signForm.money = selectedActivity.value.money;
+  // 默认金额取活动金额(活动级 money 字符串两位小数 → number);可改
+  signForm.money = Number(selectedActivity.value.money) || null;
   signForm.recommendUserId = '';
   signVisible.value = true;
 }
@@ -118,7 +120,7 @@ async function doSign(): Promise<void> {
   signBusy.value = true;
   try {
     const body: { userId: string; money?: string; recommendUserId?: string } = { userId: signForm.userId };
-    if (signForm.money) body.money = signForm.money;
+    if (signForm.money && signForm.money > 0) body.money = moneyPlain(signForm.money);
     // 老带新(type=2)才带推荐人(可空)
     if (selectedActivity.value.type === 2 && signForm.recommendUserId) body.recommendUserId = signForm.recommendUserId;
     const r = await signActivity(selectedActivity.value.id, body);
@@ -173,9 +175,10 @@ const signStatusText: Record<number, string> = { 1: '有效', '-1': '已取消'
     >
       <template #toolbar>
         <el-input v-model="filters.keyword" placeholder="搜索活动名称" clearable class="toolbar-input" />
-        <el-select v-model="filters.type" placeholder="类型" clearable class="toolbar-select">
-          <el-option v-for="(t, k) in typeText" :key="k" :value="Number(k)" :label="t" />
-        </el-select>
+        <el-segmented
+          v-model="filters.type"
+          :options="[{ label: '全部', value: 'all' }, { label: '老带新', value: '2' }, { label: '充值赠送', value: '3' }, { label: '特殊费用', value: '4' }]"
+        />
       </template>
     </ListPage>
 
@@ -227,7 +230,7 @@ const signStatusText: Record<number, string> = { 1: '有效', '-1': '已取消'
           </el-select>
         </el-form-item>
         <el-form-item label="报名金额">
-          <el-input v-model="signForm.money" placeholder="如 100.00(可留空)" />
+          <el-input-number v-model="signForm.money" :min="0" :precision="2" :step="100" controls-position="right" style="width: 100%" />
         </el-form-item>
         <el-form-item v-if="selectedActivity?.type === 2" label="推荐人">
           <el-select v-model="signForm.recommendUserId" filterable clearable placeholder="老带新推荐人(可空)" style="width: 100%">

+ 7 - 6
src/views/academic/expenditure/index.vue

@@ -49,9 +49,10 @@ onMounted(async () => {
 });
 
 /* ================= 收支记录(端点 60 GET) ================= */
-const typeFilter = ref<number | undefined>(undefined);
+/** 方向筛选:'all' 哨兵(el-segmented);fetcher 剥离为 undefined */
+const typeFilter = ref<string>('all');
 const { rows, total, page, pageSize, loading, error, fetchList } = useListPage<ExpenditureRecordDTO>({
-  fetcher: (p, ps) => listExpenditures({ ...scope.toQuery(), type: typeFilter.value, page: p, pageSize: ps }),
+  fetcher: (p, ps) => listExpenditures({ ...scope.toQuery(), type: typeFilter.value === 'all' ? undefined : Number(typeFilter.value), page: p, pageSize: ps }),
 });
 onMounted(() => {
   void fetchList();
@@ -142,10 +143,10 @@ async function doAdd(): Promise<void> {
       @search="onSearch"
     >
       <template #toolbar>
-        <el-select v-model="typeFilter" placeholder="方向" clearable class="toolbar-select">
-          <el-option :value="1" label="支出" />
-          <el-option :value="2" label="收入" />
-        </el-select>
+        <el-segmented
+          v-model="typeFilter"
+          :options="[{ label: '全部', value: 'all' }, { label: '支出', value: '1' }, { label: '收入', value: '2' }]"
+        />
         <el-button type="primary" @click="openAdd">新增收支记录</el-button>
       </template>
     </ListPage>

+ 14 - 12
src/views/academic/fee/index.vue

@@ -28,6 +28,7 @@ import ListPage from '@/components/ListPage.vue';
 import type { ListColumn } from '@/components/list-column';
 import { useListPage } from '@/composables/use-list-page';
 import { useScope } from '@/store/scope';
+import { moneyPlain } from '@/utils/format';
 import { formatDateTime } from '@/utils/format';
 
 const scope = useScope();
@@ -101,8 +102,9 @@ function onLogSearch(): void {
 }
 
 /* ================= 账户操作对话框 ================= */
-const opForm = reactive({ money: '', remark: '' });
-const transferForm = reactive({ fromCampus: 0, toCampus: 0, money: '' });
+/** 金额本地用 number(el-input-number 绑定);提交时 moneyPlain 转两位小数字符串(契约 §1.4) */
+const opForm = reactive({ money: null as number | null, remark: '' });
+const transferForm = reactive({ fromCampus: 0, toCampus: 0, money: null as number | null });
 const busy = ref(false);
 
 function nextRequestId(prefix: string): string {
@@ -128,13 +130,13 @@ async function runOp(fn: () => Promise<{ code: number; message: string }>): Prom
   }
 }
 function doRecharge(): void {
-  void runOp(() => recharge({ userId: selectedUserId.value, campusId: selectedCampusId.value, money: opForm.money, requestId: nextRequestId('rc'), institutionId: scope.current.institutionId }));
+  void runOp(() => recharge({ userId: selectedUserId.value, campusId: selectedCampusId.value, money: moneyPlain(opForm.money ?? 0), requestId: nextRequestId('rc'), institutionId: scope.current.institutionId }));
 }
 function doExpenditure(): void {
-  void runOp(() => expenditure({ userId: selectedUserId.value, campusId: selectedCampusId.value, money: opForm.money, institutionId: scope.current.institutionId }));
+  void runOp(() => expenditure({ userId: selectedUserId.value, campusId: selectedCampusId.value, money: moneyPlain(opForm.money ?? 0), institutionId: scope.current.institutionId }));
 }
 function doRefund(): void {
-  void runOp(() => refund({ userId: selectedUserId.value, campusId: selectedCampusId.value, money: opForm.money, institutionId: scope.current.institutionId }));
+  void runOp(() => refund({ userId: selectedUserId.value, campusId: selectedCampusId.value, money: moneyPlain(opForm.money ?? 0), institutionId: scope.current.institutionId }));
 }
 async function doCustomRefund(): Promise<void> {
   try {
@@ -142,7 +144,7 @@ async function doCustomRefund(): Promise<void> {
   } catch {
     return;
   }
-  void runOp(() => customRefund({ userId: selectedUserId.value, campusId: selectedCampusId.value, money: opForm.money, requestId: nextRequestId('cr'), institutionId: scope.current.institutionId }));
+  void runOp(() => customRefund({ userId: selectedUserId.value, campusId: selectedCampusId.value, money: moneyPlain(opForm.money ?? 0), requestId: nextRequestId('cr'), institutionId: scope.current.institutionId }));
 }
 async function doBadDebt(): Promise<void> {
   try {
@@ -150,7 +152,7 @@ async function doBadDebt(): Promise<void> {
   } catch {
     return;
   }
-  void runOp(() => badDebt({ userId: selectedUserId.value, campusId: selectedCampusId.value, money: opForm.money, requestId: nextRequestId('bd'), institutionId: scope.current.institutionId }));
+  void runOp(() => badDebt({ userId: selectedUserId.value, campusId: selectedCampusId.value, money: moneyPlain(opForm.money ?? 0), requestId: nextRequestId('bd'), institutionId: scope.current.institutionId }));
 }
 async function doClear(): Promise<void> {
   try {
@@ -161,14 +163,14 @@ async function doClear(): Promise<void> {
   void runOp(() => clearBalance({ userId: selectedUserId.value, campusId: selectedCampusId.value, institutionId: scope.current.institutionId }));
 }
 function doSpend(): void {
-  void runOp(() => spend({ userId: selectedUserId.value, campusId: selectedCampusId.value, spendMoney: opForm.money, institutionId: scope.current.institutionId }));
+  void runOp(() => spend({ userId: selectedUserId.value, campusId: selectedCampusId.value, spendMoney: moneyPlain(opForm.money ?? 0), institutionId: scope.current.institutionId }));
 }
 function doTransfer(): void {
   if (transferForm.fromCampus === 0 || transferForm.toCampus === 0 || transferForm.fromCampus === transferForm.toCampus) {
     ElMessage_('warning', '请选择两个不同的校区');
     return;
   }
-  void runOp(() => transfer({ userId: selectedUserId.value, fromCampusId: transferForm.fromCampus, toCampusId: transferForm.toCampus, money: transferForm.money, institutionId: scope.current.institutionId }));
+  void runOp(() => transfer({ userId: selectedUserId.value, fromCampusId: transferForm.fromCampus, toCampusId: transferForm.toCampus, money: moneyPlain(transferForm.money ?? 0), institutionId: scope.current.institutionId }));
 }
 
 /* ================= 资金工单(端点 12~16) ================= */
@@ -194,7 +196,7 @@ function onReqSearch(): void {
   void fetchReqs();
 }
 function doCreateRequest(): void {
-  void runOp(() => createRequest({ type: 1, userId: selectedUserId.value, campusId: selectedCampusId.value, money: opForm.money, remark: opForm.remark || '工单', institutionId: scope.current.institutionId }).then((r) => ({ code: r.code, message: r.message })));
+  void runOp(() => createRequest({ type: 1, userId: selectedUserId.value, campusId: selectedCampusId.value, money: moneyPlain(opForm.money ?? 0), remark: opForm.remark || '工单', institutionId: scope.current.institutionId }).then((r) => ({ code: r.code, message: r.message })));
 }
 async function doApprove(r: BalanceRequestDTO): Promise<void> {
   await runOp(() => approveRequest(r.id, { ...scope.toQuery(), campusId: selectedCampusId.value || undefined }).then((x) => ({ code: x.code, message: x.message })));
@@ -274,7 +276,7 @@ const ops = [
       <el-tab-pane label="账户操作">
         <div class="fee-page__ops">
           <div class="fee-page__op-form">
-            <el-input v-model="opForm.money" placeholder="金额(如 100.00)" style="width: 160px" />
+            <el-input-number v-model="opForm.money" :min="0" :precision="2" :step="100" controls-position="right" style="width: 160px" />
             <el-input v-model="opForm.remark" placeholder="备注(工单用)" style="width: 200px" />
           </div>
           <div class="fee-page__op-btns">
@@ -288,7 +290,7 @@ const ops = [
               <el-select v-model="transferForm.toCampus" placeholder="转入校区" style="width: 140px">
                 <el-option v-for="c in campusOpts" :key="c.campusId" :value="c.campusId" :label="c.campusName" />
               </el-select>
-              <el-input v-model="transferForm.money" placeholder="转结金额" style="width: 120px" />
+              <el-input-number v-model="transferForm.money" :min="0" :precision="2" :step="100" controls-position="right" style="width: 120px" />
               <el-button type="warning" :disabled="!selectedUserId" :loading="busy" @click="doTransfer">转结</el-button>
               <el-button type="info" :disabled="!selectedUserId" :loading="busy" @click="doCreateRequest">建资金工单</el-button>
             </div>

+ 114 - 66
src/views/academic/schedule/index.vue

@@ -47,7 +47,7 @@ import ListPage from '@/components/ListPage.vue';
 import type { ListColumn } from '@/components/list-column';
 import { useListPage } from '@/composables/use-list-page';
 import { useScope } from '@/store/scope';
-import { formatDateTime, formatMoney } from '@/utils/format';
+import { formatDateTime, formatMoney, moneyPlain } from '@/utils/format';
 
 const scope = useScope();
 
@@ -103,9 +103,11 @@ function todayStr(): string {
 }
 
 /* ================= ① 课程列表(端点 44) ================= */
-const filters = reactive<{ keyword: string; status: number | undefined }>({ keyword: '', status: undefined });
+/** 状态筛选:'all' = 全部(el-segmented 哨兵值;fetcher 里剥离,不传 undefined 之外的脏值) */
+const filters = reactive<{ keyword: string; status: string }>({ keyword: '', status: 'all' });
 const { rows: courses, total, page, pageSize, loading, error, fetchList } = useListPage<CourseDTO>({
-  fetcher: (p, ps) => listCourses({ ...scope.toQuery(), ...filters, page: p, pageSize: ps }),
+  fetcher: (p, ps) =>
+    listCourses({ ...scope.toQuery(), keyword: filters.keyword, status: filters.status === 'all' ? undefined : Number(filters.status), page: p, pageSize: ps }),
 });
 const courseStatusText: Record<number, string> = { 0: '未开始', 1: '进行中' };
 const columns: ListColumn<CourseDTO>[] = [
@@ -140,7 +142,7 @@ async function loadClasses(): Promise<void> {
   }
   classLoading.value = true;
   try {
-    const r = await listClasses(selectedCourseId.value, { pageSize: 200 });
+    const r = await listClasses(selectedCourseId.value, { ...scope.toQuery(), pageSize: 200 });
     if (r.code === 200) classRows.value = r.data?.list ?? [];
     else ElMessage_('error', r.message || '排班规则加载失败');
   } catch {
@@ -181,6 +183,23 @@ function weeksToString(): string {
   if (ws.includes(-1)) return '-1';
   return [...ws].sort((a, b) => a - b).map(String).join(',');
 }
+/**
+ * 周互斥切换(-1 全周 与 0..6 具体周不可共存):
+ *   - 点"全周"(-1):清空所有具体周,只留 -1;
+ *   - 点具体周:移除 -1,再 toggle 该周;
+ *   - 取消"全周":若还有具体周则保留,否则清空。
+ */
+function toggleWeek(v: number): void {
+  if (v === -1) {
+    classForm.weeks = classForm.weeks.includes(-1) ? [] : [-1];
+    return;
+  }
+  const rest = classForm.weeks.filter((x) => x !== -1);
+  const idx = rest.indexOf(v);
+  if (idx >= 0) rest.splice(idx, 1);
+  else rest.push(v);
+  classForm.weeks = rest.length > 0 ? rest : [];
+}
 async function doCreateClass(): Promise<void> {
   if (!selectedCourseId.value) return;
   if (!classForm.teacher || !weeksToString() || !classForm.start || !classForm.end) {
@@ -217,15 +236,17 @@ async function doCreateClass(): Promise<void> {
 }
 
 /* ================= ③ 课表区(端点 48/49/50/51) ================= */
-const recFilters = reactive<{ range: [string, string] | []; status: number | undefined }>({ range: [], status: undefined });
+/** 状态筛选:'all' 哨兵(el-segmented);fetcher 剥离为 undefined */
+const recFilters = reactive<{ range: [string, string] | []; status: string }>({ range: [], status: 'all' });
 const { rows: records, total: recTotal, page: recPage, pageSize: recPageSize, loading: recLoading, error: recError, fetchList: fetchRecords } =
   useListPage<CourseRecordDTO>({
     fetcher: (p, ps) =>
       listRecords(selectedCourseId.value ?? 0, {
+        ...scope.toQuery(),
         // ⚠ 时间窗闭区间:to 含当日(契约 §3.C 显式例外)——daterange 原样传,不做 +1 天
         from: recFilters.range[0] || undefined,
         to: recFilters.range[1] || undefined,
-        status: recFilters.status,
+        status: recFilters.status === 'all' ? undefined : Number(recFilters.status),
         page: p,
         pageSize: ps,
       }),
@@ -333,9 +354,19 @@ async function doFinish(rec: CourseRecordDTO): Promise<void> {
 const attVisible = ref(false);
 const attBusy = ref(false);
 const attRecord = ref<CourseRecordDTO | null>(null);
-interface AttRow extends AttendanceItem {
+/**
+ * 打卡行(端点 52 提交前):金额字段本地用 number(el-input-number 绑定),
+ * 提交时经 moneyPlain 转回契约要求的两位小数字符串。
+ */
+interface AttRow {
+  userId: string;
   /** 展示名(53 realName 可空 → 兜底 userId) */
   name: string;
+  /** 上课类型(1 正常 / 2 插班 / 3 赠送) */
+  type: number;
+  pay: number;
+  cost: number;
+  giveCost: number;
 }
 const attRows = ref<AttRow[]>([]);
 const attTypeText: Record<number, string> = { 1: '正常', 2: '插班', 3: '赠送' };
@@ -345,7 +376,7 @@ async function openAttendance(rec: CourseRecordDTO): Promise<void> {
   attVisible.value = true;
   if (!selectedCourseId.value) return;
   // 初值 = 端点 53 课程级报课学生(risk-1 免后端方案:52 提交时后端按该课实际学生匹配,不在课内的 userId 不生效)
-  const r = await listCourseStudents(selectedCourseId.value, { pageSize: 200 });
+  const r = await listCourseStudents(selectedCourseId.value, { ...scope.toQuery(), pageSize: 200 });
   if (r.code !== 200) {
     ElMessage_('error', r.message || '学生初值加载失败');
     return;
@@ -354,20 +385,23 @@ async function openAttendance(rec: CourseRecordDTO): Promise<void> {
     userId: s.userId,
     name: s.realName ?? s.userId,
     type: 1,
-    pay: s.pay,
-    cost: s.cost,
-    giveCost: s.giveCost,
+    // 金额字符串两位小数 → number(el-input-number 绑定;提交时 formatMoney 回字符串)
+    pay: Number(s.pay) || 0,
+    cost: Number(s.cost) || 0,
+    giveCost: Number(s.giveCost) || 0,
   }));
 }
 async function doAttendance(): Promise<void> {
   const rec = attRecord.value;
   if (!rec) return;
+  // number → 金额字符串两位小数(契约:金额字符串两位小数;提交位用 moneyPlain 不带千分位;0/undefined 不下发 = 不改)
+  const toMoney = (v: number | null | undefined): string | undefined => (v && v > 0 ? moneyPlain(v) : undefined);
   const list: AttendanceItem[] = attRows.value.map(({ userId, type, pay, cost, giveCost }) => ({
     userId,
     type,
-    ...(pay ? { pay } : {}),
-    ...(cost ? { cost } : {}),
-    ...(giveCost ? { giveCost } : {}),
+    ...(toMoney(pay) ? { pay: toMoney(pay)! } : {}),
+    ...(toMoney(cost) ? { cost: toMoney(cost)! } : {}),
+    ...(toMoney(giveCost) ? { giveCost: toMoney(giveCost)! } : {}),
   }));
   attBusy.value = true;
   try {
@@ -398,7 +432,7 @@ async function loadStudents(): Promise<void> {
   }
   stuLoading.value = true;
   try {
-    const r = await listCourseStudents(selectedCourseId.value, { pageSize: 200 });
+    const r = await listCourseStudents(selectedCourseId.value, { ...scope.toQuery(), pageSize: 200 });
     if (r.code === 200) stuRows.value = r.data?.list ?? [];
     else ElMessage_('error', r.message || '报课学生加载失败');
   } catch {
@@ -412,31 +446,34 @@ const stuStatusText: Record<number, string> = { 0: '未确认', 1: '在读', 2:
 /** 报名对话框(端点 54;不写资金流水、不动余额;重复 → 3143) */
 const enrollVisible = ref(false);
 const enrollBusy = ref(false);
+/** 金额字段统一 number(el-input-number 绑定);提交时 moneyPlain 转两位小数字符串 */
 const enrollForm = reactive({
   userId: '',
-  pay: '',
-  lessonCost: '',
+  pay: null as number | null,
+  lessonCost: null as number | null,
   number: 1,
-  giveNumber: '',
-  discountCash: '',
-  giveCost: '',
+  giveNumber: null as number | null,
+  discountCash: null as number | null,
+  giveCost: null as number | null,
   campusId: 0,
   recommenderUserId: '',
   remark: '',
 });
 function openEnroll(): void {
   enrollForm.userId = studentOpts.value[0]?.userId ?? '';
-  enrollForm.pay = '';
-  enrollForm.lessonCost = '';
+  enrollForm.pay = null;
+  enrollForm.lessonCost = null;
   enrollForm.number = 1;
-  enrollForm.giveNumber = '';
-  enrollForm.discountCash = '';
-  enrollForm.giveCost = '';
+  enrollForm.giveNumber = null;
+  enrollForm.discountCash = null;
+  enrollForm.giveCost = null;
   enrollForm.campusId = scope.current.campusId;
   enrollForm.recommenderUserId = '';
   enrollForm.remark = '';
   enrollVisible.value = true;
 }
+/** 推荐人选项:排除被报学生本人(老带新不能自推荐) */
+const recommenderOpts = computed(() => studentOpts.value.filter((m) => m.userId !== enrollForm.userId));
 async function doEnroll(): Promise<void> {
   if (!selectedCourseId.value) return;
   if (!enrollForm.userId || !enrollForm.pay || !enrollForm.lessonCost || enrollForm.number <= 0) {
@@ -447,12 +484,12 @@ async function doEnroll(): Promise<void> {
   try {
     const r = await enrollStudent(selectedCourseId.value, {
       userId: enrollForm.userId,
-      pay: enrollForm.pay,
-      lessonCost: enrollForm.lessonCost,
+      pay: moneyPlain(enrollForm.pay ?? 0),
+      lessonCost: moneyPlain(enrollForm.lessonCost ?? 0),
       number: enrollForm.number,
-      ...(enrollForm.giveNumber ? { giveNumber: Number(enrollForm.giveNumber) } : {}),
-      ...(enrollForm.discountCash ? { discountCash: enrollForm.discountCash } : {}),
-      ...(enrollForm.giveCost ? { giveCost: enrollForm.giveCost } : {}),
+      ...(enrollForm.giveNumber && enrollForm.giveNumber > 0 ? { giveNumber: enrollForm.giveNumber } : {}),
+      ...(enrollForm.discountCash && enrollForm.discountCash > 0 ? { discountCash: moneyPlain(enrollForm.discountCash) } : {}),
+      ...(enrollForm.giveCost && enrollForm.giveCost > 0 ? { giveCost: moneyPlain(enrollForm.giveCost) } : {}),
       ...(enrollForm.campusId ? { campusId: enrollForm.campusId } : {}),
       ...(enrollForm.recommenderUserId ? { recommenderUserId: enrollForm.recommenderUserId } : {}),
       ...(enrollForm.remark ? { remark: enrollForm.remark } : {}),
@@ -497,15 +534,15 @@ const arrColumns: ListColumn<ArrearsDTO>[] = [
 const actVisible = ref(false);
 const actBusy = ref(false);
 const actTarget = ref<ArrearsDTO | null>(null);
-const actMoney = ref('');
+const actMoney = ref<number | null>(null);
 function openCounteract(row: ArrearsDTO): void {
   actTarget.value = row;
-  actMoney.value = row.remainderReceivable;
+  actMoney.value = Number(row.remainderReceivable) || 0;
   actVisible.value = true;
 }
 async function doCounteract(): Promise<void> {
   const t = actTarget.value;
-  if (!t || !actMoney.value || Number(actMoney.value) <= 0) {
+  if (!t || !actMoney.value || actMoney.value <= 0) {
     ElMessage_('warning', '请输入有效核销金额(>0)');
     return;
   }
@@ -515,7 +552,7 @@ async function doCounteract(): Promise<void> {
       studentId: t.studentId,
       institutionId: scope.current.institutionId,
       campusId: scope.current.campusId,
-      money: actMoney.value,
+      money: moneyPlain(actMoney.value),
     });
     if (r.code === 200 && r.data) {
       ElMessage_('success', `核销完成:${studentName(t.studentId)} 核销 ${r.data.amount}`);
@@ -549,9 +586,10 @@ async function doCounteract(): Promise<void> {
     >
       <template #toolbar>
         <el-input v-model="filters.keyword" placeholder="搜索课程名称" clearable class="toolbar-input" />
-        <el-select v-model="filters.status" placeholder="状态" clearable class="toolbar-select">
-          <el-option v-for="(t, k) in courseStatusText" :key="k" :value="Number(k)" :label="t" />
-        </el-select>
+        <el-segmented
+          v-model="filters.status"
+          :options="[{ label: '全部', value: 'all' }, { label: '未开始', value: '0' }, { label: '进行中', value: '1' }]"
+        />
       </template>
     </ListPage>
 
@@ -559,9 +597,8 @@ async function doCounteract(): Promise<void> {
     <div class="schedule-page__section">
       <div class="schedule-page__head">
         <span class="schedule-page__title">排班规则</span>
-        <el-select v-model="selectedCourseId" filterable placeholder="选择课程" class="schedule-page__course-select">
-          <el-option v-for="c in courses" :key="c.id" :value="c.id" :label="c.name" />
-        </el-select>
+        <el-tag v-if="selectedCourse" type="success" effect="light">已选:{{ selectedCourse.name }}(点上方课程行切换)</el-tag>
+        <span v-else class="schedule-page__hint">点击上方课程行选中课程</span>
         <el-button type="primary" :disabled="!selectedCourseId" @click="openClass">新建排班规则</el-button>
       </div>
       <el-table v-if="selectedCourse" :data="classRows" :loading="classLoading" size="small" border>
@@ -598,9 +635,10 @@ async function doCounteract(): Promise<void> {
           end-placeholder="结束日期(含当日)"
           class="schedule-page__date-range"
         />
-        <el-select v-model="recFilters.status" placeholder="状态" clearable class="schedule-page__rec-status">
-          <el-option v-for="(t, k) in recStatusText" :key="k" :value="Number(k)" :label="t" />
-        </el-select>
+        <el-segmented
+          v-model="recFilters.status"
+          :options="[{ label: '全部', value: 'all' }, { label: '未开始', value: '0' }, { label: '上课中', value: '1' }, { label: '已结束', value: '2' }]"
+        />
         <el-button @click="onRecSearch" :disabled="!selectedCourseId">刷新</el-button>
         <el-button type="warning" :disabled="!selectedCourseId" @click="openGenerate">生成课表</el-button>
       </div>
@@ -711,10 +749,17 @@ async function doCounteract(): Promise<void> {
           </el-select>
         </el-form-item>
         <el-form-item label="上课周">
-          <el-select v-model="classForm.weeks" multiple placeholder="选择周(-1 = 全周,与具体周互斥)" style="width: 100%">
-            <el-option :value="-1" label="全周(-1)" />
-            <el-option v-for="(t, k) in WEEK_NAMES" :key="k" :value="Number(k)" :label="t" />
-          </el-select>
+          <div class="schedule-page__week">
+            <el-checkbox :model-value="classForm.weeks.includes(-1)" @change="toggleWeek(-1)">全周</el-checkbox>
+            <el-checkbox
+              v-for="(t, k) in WEEK_NAMES"
+              :key="k"
+              :model-value="classForm.weeks.includes(Number(k))"
+              @change="toggleWeek(Number(k))"
+            >
+              {{ t }}
+            </el-checkbox>
+          </div>
         </el-form-item>
         <el-form-item label="上课时间">
           <el-time-picker v-model="classForm.start" value-format="HH:mm:ss" format="HH:mm:ss" placeholder="选择上课时间" style="width: 100%" />
@@ -764,19 +809,19 @@ async function doCounteract(): Promise<void> {
             </el-select>
           </template>
         </el-table-column>
-        <el-table-column label="课程费用" width="120">
+        <el-table-column label="课程费用" width="130">
           <template #default="{ row }">
-            <el-input v-model="row.pay" size="small" placeholder="金额" />
+            <el-input-number v-model="row.pay" size="small" :min="0" :precision="2" :step="100" controls-position="right" style="width: 100%" />
           </template>
         </el-table-column>
-        <el-table-column label="课时费用" width="120">
+        <el-table-column label="课时费用" width="130">
           <template #default="{ row }">
-            <el-input v-model="row.cost" size="small" placeholder="金额" />
+            <el-input-number v-model="row.cost" size="small" :min="0" :precision="2" :step="100" controls-position="right" style="width: 100%" />
           </template>
         </el-table-column>
-        <el-table-column label="赠送" width="110">
+        <el-table-column label="赠送" width="120">
           <template #default="{ row }">
-            <el-input v-model="row.giveCost" size="small" placeholder="可空" />
+            <el-input-number v-model="row.giveCost" size="small" :min="0" :precision="2" :step="0" controls-position="right" style="width: 100%" />
           </template>
         </el-table-column>
       </el-table>
@@ -805,22 +850,22 @@ async function doCounteract(): Promise<void> {
           </el-select>
         </el-form-item>
         <el-form-item label="课程费用">
-          <el-input v-model="enrollForm.pay" placeholder="必填,如 10000.00" />
+          <el-input-number v-model="enrollForm.pay" :min="0" :precision="2" :step="100" controls-position="right" style="width: 100%" />
         </el-form-item>
         <el-form-item label="单节课时费用">
-          <el-input v-model="enrollForm.lessonCost" placeholder="必填,如 200.00" />
+          <el-input-number v-model="enrollForm.lessonCost" :min="0" :precision="2" :step="100" controls-position="right" style="width: 100%" />
         </el-form-item>
         <el-form-item label="课次">
           <el-input-number v-model="enrollForm.number" :min="1" />
         </el-form-item>
         <el-form-item label="赠送课次">
-          <el-input v-model="enrollForm.giveNumber" placeholder="可空" />
+          <el-input-number v-model="enrollForm.giveNumber" :min="0" :precision="0" controls-position="right" style="width: 100%" />
         </el-form-item>
         <el-form-item label="现金折扣">
-          <el-input v-model="enrollForm.discountCash" placeholder="可空,金额" />
+          <el-input-number v-model="enrollForm.discountCash" :min="0" :precision="2" :step="0" controls-position="right" style="width: 100%" />
         </el-form-item>
         <el-form-item label="赠送金额">
-          <el-input v-model="enrollForm.giveCost" placeholder="可空,金额" />
+          <el-input-number v-model="enrollForm.giveCost" :min="0" :precision="2" :step="0" controls-position="right" style="width: 100%" />
         </el-form-item>
         <el-form-item label="校区">
           <el-select v-model="enrollForm.campusId" style="width: 100%">
@@ -829,8 +874,8 @@ async function doCounteract(): Promise<void> {
           </el-select>
         </el-form-item>
         <el-form-item label="推荐人">
-          <el-select v-model="enrollForm.recommenderUserId" filterable clearable placeholder="可空" style="width: 100%">
-            <el-option v-for="m in studentOpts" :key="m.userId" :value="m.userId" :label="m.realName" />
+          <el-select v-model="enrollForm.recommenderUserId" filterable clearable placeholder="可空(不含被报学生)" style="width: 100%">
+            <el-option v-for="m in recommenderOpts" :key="m.userId" :value="m.userId" :label="m.realName" />
           </el-select>
         </el-form-item>
         <el-form-item label="备注">
@@ -861,7 +906,7 @@ async function doCounteract(): Promise<void> {
           <span>{{ actTarget?.remainderReceivable }}</span>
         </el-form-item>
         <el-form-item label="核销金额">
-          <el-input v-model="actMoney" placeholder="金额(>0)" />
+          <el-input-number v-model="actMoney" :min="0.01" :precision="2" :step="0.01" controls-position="right" style="width: 100%" />
         </el-form-item>
       </el-form>
       <template #footer>
@@ -891,14 +936,17 @@ async function doCounteract(): Promise<void> {
   font-weight: 600;
   min-width: 130px;
 }
-.schedule-page__course-select {
-  width: 240px;
-}
 .schedule-page__date-range {
   width: 260px;
 }
-.schedule-page__rec-status {
-  width: 120px;
+.schedule-page__week {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 4px 14px;
+}
+.schedule-page__week :deep(.el-checkbox) {
+  margin-right: 0;
+  height: 28px;
 }
 .schedule-page__hint {
   font-size: var(--app-font-sm);

+ 18 - 12
src/views/org/institution/list.vue

@@ -9,7 +9,7 @@
  * 作用域纪律(README §3.3-4):查询参数里的 institutionId/campusId 只从 useScope().toQuery() 取,
  * 页面不出现任何硬编码作用域数值。
  */
-import { listInstitutions, type InstitutionDTO, type InstitutionFilter } from '@/api/org/institution';
+import { listInstitutions, type InstitutionDTO } from '@/api/org/institution';
 import ListPage from '@/components/ListPage.vue';
 import type { ListColumn } from '@/components/list-column';
 import { useListPage } from '@/composables/use-list-page';
@@ -18,11 +18,10 @@ import { formatDateTime, formatStatus } from '@/utils/format';
 
 const scope = useScope();
 
-/** 筛选条件(页面级状态;工具区插槽控件绑定) */
-const filters = reactive<InstitutionFilter>({
-  keyword: '',
-  status: undefined,
-});
+/** 关键字筛选(页面级状态;工具区插槽控件绑定) */
+const keyword = ref('');
+/** 状态筛选:'all' 哨兵(el-segmented);'active'/'disabled' 对齐后端 VARCHAR 枚举 */
+const statusSeg = ref<string>('all');
 
 /**
  * 列表状态(useListPage 收口;fetcher 内并入作用域)。
@@ -30,7 +29,14 @@ const filters = reactive<InstitutionFilter>({
  * 若整对象返回(lp.page),模板里是未解包的 Ref 对象,v-model 会写入失败。
  */
 const { rows, total, page, pageSize, loading, error, fetchList } = useListPage<InstitutionDTO>({
-  fetcher: (p, ps) => listInstitutions({ ...scope.toQuery(), ...filters, page: p, pageSize: ps }),
+  fetcher: (p, ps) =>
+    listInstitutions({
+      ...scope.toQuery(),
+      keyword: keyword.value || undefined,
+      status: statusSeg.value === 'all' ? undefined : (statusSeg.value as 'active' | 'disabled'),
+      page: p,
+      pageSize: ps,
+    }),
 });
 
 /** 列定义(数据驱动;formatter 返回字符串,README §3.3-6) */
@@ -66,11 +72,11 @@ onMounted(() => {
       @search="onSearch"
     >
       <template #toolbar>
-        <el-input v-model="filters.keyword" placeholder="搜索机构名称" clearable class="toolbar-input" />
-        <el-select v-model="filters.status" placeholder="状态" clearable class="toolbar-select">
-          <el-option value="active" label="启用" />
-          <el-option value="disabled" label="停用" />
-        </el-select>
+        <el-input v-model="keyword" placeholder="搜索机构名称" clearable class="toolbar-input" />
+        <el-segmented
+          v-model="statusSeg"
+          :options="[{ label: '全部', value: 'all' }, { label: '启用', value: 'active' }, { label: '停用', value: 'disabled' }]"
+        />
       </template>
     </ListPage>
   </div>

+ 17 - 9
src/views/org/member/list.vue

@@ -12,12 +12,21 @@ import { formatDateTime, formatStatus } from '@/utils/format';
 
 const scope = useScope();
 
-/** 筛选条件(页面级状态) */
-const filters = reactive<{ keyword: string; personType: string }>({ keyword: '', personType: '' });
+/** 关键字筛选(页面级状态) */
+const keyword = ref('');
+/** 人员类型筛选:'all' 哨兵(el-segmented);'staff'/'student'/'parent' 对齐 MemberDTO.personType */
+const personTypeSeg = ref<string>('all');
 
 /** 列表状态(useListPage 收口;fetcher 并入作用域) */
 const { rows, total, page, pageSize, loading, error, fetchList } = useListPage<MemberDTO>({
-  fetcher: (p, ps) => listMembers({ ...scope.toQuery(), ...filters, page: p, pageSize: ps }),
+  fetcher: (p, ps) =>
+    listMembers({
+      ...scope.toQuery(),
+      keyword: keyword.value || undefined,
+      personType: personTypeSeg.value === 'all' ? undefined : (personTypeSeg.value as 'staff' | 'student' | 'parent'),
+      page: p,
+      pageSize: ps,
+    }),
 });
 
 /** 人员类型中文映射 */
@@ -57,12 +66,11 @@ onMounted(() => {
       @search="onSearch"
     >
       <template #toolbar>
-        <el-input v-model="filters.keyword" placeholder="搜索姓名" clearable class="toolbar-input" />
-        <el-select v-model="filters.personType" placeholder="人员类型" clearable class="toolbar-select">
-          <el-option value="staff" label="教职工" />
-          <el-option value="student" label="学生" />
-          <el-option value="parent" label="家长" />
-        </el-select>
+        <el-input v-model="keyword" placeholder="搜索姓名" clearable class="toolbar-input" />
+        <el-segmented
+          v-model="personTypeSeg"
+          :options="[{ label: '全部', value: 'all' }, { label: '教职工', value: 'staff' }, { label: '学生', value: 'student' }, { label: '家长', value: 'parent' }]"
+        />
       </template>
     </ListPage>
   </div>