9 Commits

Author SHA1 Message Date
weli ccd54f537a docs: replace CLAUDE.md with symlink to plan_skills/projects/huike-front.md
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-04 09:43:00 +08:00
weli cfcf1ad16e Use merged clazz query endpoint in calendar view
Switches search() and searchForSubsidiaries() to the new
find_all_within_date_range endpoint, cutting API calls per
navigation from 2+2N to 1+N.

Refs #5

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-04 08:45:53 +08:00
weli beda8b5de7 Merge branch 'master' of https://ci.moicen.com/weli/huike-front 2026-05-03 20:16:58 +08:00
weli 994cf0c892 fix(daka): separate pull-refresh and list loading states
van-pull-refresh and van-list shared the same state.loading, causing
the loading spinner to never disappear when API calls completed.
Split into state.refreshing (pull-refresh) and state.loading (list).
Add try/finally to guarantee both states always reset.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 14:33:30 +08:00
weli c6788d210b feat(dept): add department switcher component
Add DeptSwitcher that shows department name + switch button when
user is in a multi-department org. Uses Van ActionSheet for
department selection. Integrated into user-profile.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 13:41:01 +08:00
weli 73f9741a88 chore: deploy_ver 改为 YYYYMMDD.NNN 格式支持每日多次部署
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 11:32:13 +08:00
weli 9f39a822f7 chore: 永久保留 deploy_ver debug 日志 + CLAUDE.md 记录更新规则
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 11:31:01 +08:00
weli 2fb5ffadc8 chore: 移除临时 debug 日志
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 11:29:38 +08:00
weli 08571869b5 debug: App.vue onMounted 添加版本号日志用于验证缓存刷新
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 11:27:15 +08:00
7 changed files with 208 additions and 34 deletions
-7
View File
@@ -1,7 +0,0 @@
# huike-front 项目指南
## vConsole 显示规则
vConsole 仅在当前用户有 `SYS_CAN_SUDO` tag 时显示。判断逻辑在 `src/App.vue:updateVConsoleVisibility()`
Tags 通过 `get_all_tags_of_the_user` API 异步加载(`src/store/user.ts:getTags()`),在 `setCurrentUser()` 之后执行。vConsole 的 watch 需要 `{ deep: true }` 以及独立的 `store.current.tags` watch 来覆盖异步加载完成后的触发。
Symlink
+1
View File
@@ -0,0 +1 @@
../plan_skills/projects/huike-front.md
+1
View File
@@ -70,6 +70,7 @@ export default defineComponent({
const badge_props: Partial<BadgeProps> = { showZero: false, max: 99 } const badge_props: Partial<BadgeProps> = { showZero: false, max: 99 }
onMounted(() => { onMounted(() => {
console.debug('[App.vue] deploy_ver=20260504.001');
let vconsole = document.getElementById("__vconsole"); let vconsole = document.getElementById("__vconsole");
if (vconsole) { if (vconsole) {
vconsole.hidden = true; vconsole.hidden = true;
+66
View File
@@ -0,0 +1,66 @@
<template>
<div v-if="hasMultipleDepts" class="current-dept">
<span>当前部门{{ currentDeptName || '未选择' }}</span>
<van-button size="mini" plain type="primary" @click="showPicker = true">切换部门</van-button>
<van-action-sheet
v-model:show="showPicker"
:actions="deptActions"
@select="onSelect"
cancel-text="取消"
/>
</div>
</template>
<script lang="ts">
import { defineComponent, ref, computed } from 'vue';
import { ActionSheet, Button } from 'vant';
import useOrg from '~/store/org';
export default defineComponent({
name: 'dept-switcher',
components: {
[ActionSheet.name!]: ActionSheet,
[Button.name!]: Button,
},
setup() {
const { store, switchDepartment } = useOrg();
const showPicker = ref(false);
const hasMultipleDepts = computed(() => store.departments.length > 1);
const currentDeptName = computed(() => {
if (!store.currentDepartmentId) return '';
const dept = store.departments.find((d) => d.id === store.currentDepartmentId);
return dept?.dept_name || '';
});
const deptActions = computed(() =>
store.departments.map((d) => ({
name: d.dept_name,
id: d.id,
}))
);
const onSelect = async (action: { name: string; id: string }) => {
showPicker.value = false;
if (action.id !== store.currentDepartmentId) {
await switchDepartment(action.id);
window.location.reload();
}
};
return { hasMultipleDepts, currentDeptName, showPicker, deptActions, onSelect };
},
});
</script>
<style lang="less" scoped>
.current-dept {
display: flex;
justify-content: center;
align-items: center;
gap: 0.12rem;
color: #666;
margin-bottom: 0.12rem;
}
</style>
+8 -2
View File
@@ -20,6 +20,7 @@
<span>当前机构{{ currentOrgName || '未选择机构' }}</span> <span>当前机构{{ currentOrgName || '未选择机构' }}</span>
<van-button size="mini" plain type="primary" @click="goOrgSelect">切换机构</van-button> <van-button size="mini" plain type="primary" @click="goOrgSelect">切换机构</van-button>
</div> </div>
<dept-switcher />
<div v-if="isStudent" class="current-teacher"> <div v-if="isStudent" class="current-teacher">
<span>当前老师{{ currentTeacherName || '未选择老师' }}</span> <span>当前老师{{ currentTeacherName || '未选择老师' }}</span>
<van-button size="mini" plain type="primary" @click="goTeacherSelect">切换老师</van-button> <van-button size="mini" plain type="primary" @click="goTeacherSelect">切换老师</van-button>
@@ -45,6 +46,7 @@ import { useRouter } from "vue-router";
import {UpyunAccess} from "~/utils/upyun"; import {UpyunAccess} from "~/utils/upyun";
import {CurrentUserRole, HtyAuthToken, HtySudoToken} from "~/utils"; import {CurrentUserRole, HtyAuthToken, HtySudoToken} from "~/utils";
import useOrg from "~/store/org"; import useOrg from "~/store/org";
import DeptSwitcher from "~/components/dept-switcher.vue";
import useTeacher from "~/store/teacher"; import useTeacher from "~/store/teacher";
export default defineComponent({ export default defineComponent({
@@ -54,13 +56,14 @@ export default defineComponent({
[Icon.name!]: Icon, [Icon.name!]: Icon,
[Tag.name!]: Tag, [Tag.name!]: Tag,
[ActionSheet.name!]: ActionSheet, [ActionSheet.name!]: ActionSheet,
[Button.name!]: Button [Button.name!]: Button,
DeptSwitcher,
}, },
setup() { setup() {
const switching = ref(false); const switching = ref(false);
const router = useRouter(); const router = useRouter();
const { store, chooseRole, update, logout } = useUser(router); const { store, chooseRole, update, logout } = useUser(router);
const { store: orgStore, loadMyOrgs } = useOrg(); const { store: orgStore, loadMyOrgs, loadMyDepartments } = useOrg();
const { store: teacherStore, switchTeacher } = useTeacher(); const { store: teacherStore, switchTeacher } = useTeacher();
const user = computed(() => store.current) const user = computed(() => store.current)
const state = reactive({ const state = reactive({
@@ -167,6 +170,9 @@ export default defineComponent({
if (!orgStore.orgs.length) { if (!orgStore.orgs.length) {
await loadMyOrgs(); await loadMyOrgs();
} }
if (!orgStore.departments.length) {
await loadMyDepartments();
}
}); });
return { switching, hasMultiRoles, roles, user, currentRole, switchRole, editAvatar, avatarUrl, editName, state, logoutUser, currentOrgName, goOrgSelect, isStudent, currentTeacherName, goTeacherSelect } return { switching, hasMultiRoles, roles, user, currentRole, switchRole, editAvatar, avatarUrl, editName, state, logoutUser, currentOrgName, goOrgSelect, isStudent, currentTeacherName, goTeacherSelect }
+63 -18
View File
@@ -209,14 +209,14 @@
<van-calendar v-model:show="state.showCalendar" :min-date="repeatMinDate" :default-date="repeatDefaultDate" @confirm="onChangeDate" type="range" color="#1989fa" /> <van-calendar v-model:show="state.showCalendar" :min-date="repeatMinDate" :default-date="repeatDefaultDate" @confirm="onChangeDate" type="range" color="#1989fa" />
<van-cell title="操作记录" is-link v-if="state.readonly && store.current.id" @click="showAuditLogPopup" style="margin-bottom: 0.2rem;" /> <van-cell title="操作记录" is-link v-if="state.readonly && store.current.id" @click="showAuditLogPopup" style="margin-bottom: 0.2rem;" />
</div> </div>
<div class="footer" v-if="is_creator || !store.current.id "> <div class="footer" v-if="(is_creator || !store.current.id) && !isStudent">
<template v-if="state.readonly"> <template v-if="state.readonly">
<van-button v-if="is_creator && store.current.id && !store.current.is_repeat" size="small" type="primary" plain block @click="openAttendance">点名消课</van-button> <van-button v-if="is_creator && store.current.id && !store.current.is_repeat" size="small" type="primary" plain block @click="openAttendance">点名消课</van-button>
<div class="footer-actions"> <div class="footer-actions">
<van-button size="mini" type="primary" @click="edit(true)" v-if="is_creator && (store.current.instance || store.current.has_root)">编辑重复排课</van-button> <van-button size="mini" type="primary" @click="edit(true)" v-if="is_creator && (store.current.instance || store.current.has_root)">编辑重复排课</van-button>
<van-button size="mini" type="primary" @click="edit(false)">编辑本次排课</van-button> <van-button size="mini" type="primary" @click="edit(false)" v-if="is_creator">编辑本次排课</van-button>
<van-button size="mini" type="danger" @click="remove(true)" v-if="is_creator && (store.current.instance || store.current.has_root)">取消重复排课</van-button> <van-button size="mini" type="danger" @click="remove(true)" v-if="is_creator && (store.current.instance || store.current.has_root)">取消重复排课</van-button>
<van-button size="mini" type="danger" @click="remove(false)">取消本次排课</van-button> <van-button size="mini" type="danger" @click="remove(false)" v-if="is_creator">取消本次排课</van-button>
</div> </div>
</template> </template>
<template v-else> <template v-else>
@@ -322,7 +322,7 @@ export default defineComponent({
}, },
setup({params}: any) { setup({params}: any) {
const router = useRouter(); const router = useRouter();
const {store, query_repeats, query, query_for_subsidiaries, query_repeats_for_subsidiaries, createOrUpdate, createInstance, removeCurrent, notify, find_clazz_attendance_by_clazz_id, batch_save_clazz_attendance, getAuditLog} = useClazz(); const {store, query_repeats, query, query_merged, query_merged_for_subsidiaries, query_for_subsidiaries, query_repeats_for_subsidiaries, createOrUpdate, createInstance, removeCurrent, notify, find_clazz_attendance_by_clazz_id, batch_save_clazz_attendance, getAuditLog} = useClazz();
const {set: setPool, setKey, getKey} = usePool(); const {set: setPool, setKey, getKey} = usePool();
const usingSupervisor = useSupervisor(); const usingSupervisor = useSupervisor();
const usingUser = useUser(); const usingUser = useUser();
@@ -461,6 +461,23 @@ export default defineComponent({
// ═══ 老师过滤(矩阵视图用) ═══ // ═══ 老师过滤(矩阵视图用) ═══
const teacherList = computed(() => { const teacherList = computed(() => {
const list: { id: string; name: string; color: string }[] = []; const list: { id: string; name: string; color: string }[] = [];
const currentRole = usingUser.store.currentRole;
if (currentRole === HtyBaseRoles.STUDENT) {
const seen = new Set<string>();
normalizedEvents.value.forEach(ev => {
if (ev.teacherId && !seen.has(ev.teacherId)) {
seen.add(ev.teacherId);
list.push({
id: ev.teacherId,
name: ev.teacherName || ev.teacherId,
color: Colors[list.length % Colors.length],
});
}
});
return list;
}
const myId = usingUser.store.current.hty_id; const myId = usingUser.store.current.hty_id;
const myName = usingUser.store.current.real_name; const myName = usingUser.store.current.real_name;
if (myId && myName) { if (myId && myName) {
@@ -562,30 +579,62 @@ export default defineComponent({
view(ev.id); view(ev.id);
}; };
const isStudent = computed(() => usingUser.store.currentRole === HtyBaseRoles.STUDENT);
const teacherColorMap = computed(() => {
const map: Record<string, string> = {};
teacherList.value.forEach(t => { map[t.id] = t.color; });
return map;
});
const draw = () => { const draw = () => {
if (!calendar.value) { console.debug('[clazz draw] skip, calendar null'); return; } if (!calendar.value) { console.debug('[clazz draw] skip, calendar null'); return; }
let api = calendar.value.getApi() let api = calendar.value.getApi()
api.removeAllEvents(); api.removeAllEvents();
store.list.forEach(item => { store.list.forEach(item => {
if (item.is_delete) return; if (item.is_delete) return;
api.addEvent({ const ev: any = {
id: item.id, id: item.id,
title: item.clazz_name, title: item.clazz_name,
start: item.start_from, start: item.start_from,
end: item.end_by, end: item.end_by,
}) };
if (isStudent.value) {
const teacherId = item.teachers?.val?.users?.vals?.[0]?.user_id;
if (teacherId && teacherColorMap.value[teacherId]) {
const color = teacherColorMap.value[teacherId];
ev.color = color;
const {r, g, b} = hexToRgb(color);
ev.borderColor = `rgba(${r}, ${g}, ${b}, 0.8)`;
}
}
api.addEvent(ev);
}) })
store.repeatList.forEach(item => { store.repeatList.forEach(item => {
if (!item.instance.id) { if (!item.instance.id) {
api.addEvent({ const rev: any = {
id: item.id, id: item.id,
title: item.instance.clazz_name, title: item.instance.clazz_name,
start: item.instance.start_from, start: item.instance.start_from,
end: item.instance.end_by, end: item.instance.end_by,
color: "rgba(55, 136, 216, 0.6)", };
borderColor: "rgba(55, 136, 216, 0.6)" if (isStudent.value) {
}) const teacherId = item.teachers?.val?.users?.vals?.[0]?.user_id;
if (teacherId && teacherColorMap.value[teacherId]) {
const color = teacherColorMap.value[teacherId];
const {r, g, b} = hexToRgb(color);
rev.color = `rgba(${r}, ${g}, ${b}, 0.6)`;
rev.borderColor = `rgba(${r}, ${g}, ${b}, 0.6)`;
} else {
rev.color = "rgba(55, 136, 216, 0.6)";
rev.borderColor = "rgba(55, 136, 216, 0.6)";
}
} else {
rev.color = "rgba(55, 136, 216, 0.6)";
rev.borderColor = "rgba(55, 136, 216, 0.6)";
}
api.addEvent(rev);
} }
}) })
@@ -693,8 +742,8 @@ export default defineComponent({
const lastQueryKey = ref(''); const lastQueryKey = ref('');
const searchForSubsidiaries = async (start_date: string, end_date: string) => { const searchForSubsidiaries = async (start_date: string, end_date: string) => {
// 主管老师角色下加载下属老师的排课 // 仅在教师或主管角色下加载下属老师的排课,学生角色不加载
if (usingUser.store.currentRole !== HtyBaseRoles.TEACHER && usingUser.store.current.roles.some(r => r.role_key === HtySuperRoles.SUPERVISOR)) { if (usingUser.store.currentRole !== HtyBaseRoles.TEACHER && usingUser.store.currentRole !== HtyBaseRoles.STUDENT && usingUser.store.current.roles.some(r => r.role_key === HtySuperRoles.SUPERVISOR)) {
subsidiaries.value = usingSupervisor.store.subsidiaries; subsidiaries.value = usingSupervisor.store.subsidiaries;
if (!subsidiaries.value.length) { if (!subsidiaries.value.length) {
await usingSupervisor.getSubsidiaries(); await usingSupervisor.getSubsidiaries();
@@ -710,10 +759,7 @@ export default defineComponent({
if (subsidiaries.value.length) { if (subsidiaries.value.length) {
const htyIds = subsidiaries.value.map(x => x.to_user_id); const htyIds = subsidiaries.value.map(x => x.to_user_id);
return Promise.all([ return query_merged_for_subsidiaries(start_date, end_date, htyIds);
query_for_subsidiaries(start_date, end_date, htyIds),
query_repeats_for_subsidiaries(start_date, end_date, htyIds),
]);
} }
} }
} }
@@ -729,8 +775,7 @@ export default defineComponent({
} }
await Promise.all([ await Promise.all([
query(start_date, end_date), query_merged(start_date, end_date),
query_repeats(start_date, end_date),
searchForSubsidiaries(start_date, end_date), searchForSubsidiaries(start_date, end_date),
]); ]);
lastQueryKey.value = key; lastQueryKey.value = key;
+13 -6
View File
@@ -11,7 +11,7 @@
<van-search shape="round" v-model.trim="state.keyword" /> <van-search shape="round" v-model.trim="state.keyword" />
</div> </div>
<div class="list"> <div class="list">
<van-pull-refresh v-model="state.loading" @refresh="onRefresh"> <van-pull-refresh v-model="state.refreshing" @refresh="onRefresh">
<van-list v-model:loading="state.loading" <van-list v-model:loading="state.loading"
:finished="state.finished" finished-text="没有更多数据了" :finished="state.finished" finished-text="没有更多数据了"
@load="onPage"> @load="onPage">
@@ -102,7 +102,9 @@ export default defineComponent({
const state = reactive({ const state = reactive({
keyword: '', keyword: '',
loading: false, finished: true, refreshing: false,
loading: false,
finished: true,
start_date: '', start_date: '',
page: 1, page: 1,
page_size: 10, page_size: 10,
@@ -123,14 +125,19 @@ export default defineComponent({
if (start_date) { if (start_date) {
params.start_date = start_date + " 00:00:00"; params.start_date = start_date + " 00:00:00";
} }
await query(params); try {
if (store.pages <= state.page) { await query(params);
state.finished = true; if (store.pages <= state.page) {
state.finished = true;
}
} finally {
state.loading = false;
state.refreshing = false;
} }
state.loading = false;
} }
const onRefresh = async () => { const onRefresh = async () => {
state.refreshing = true;
await search({page: 1, page_size: state.page_size}); await search({page: 1, page_size: state.page_size});
} }
+56 -1
View File
@@ -220,6 +220,61 @@ export default function useClazz() {
load_done() load_done()
} }
// Merged query: fetches both non-repeatable and repeatable in one API call
async function query_merged(start_from: string, end_by: string) {
let {currentRole, current:{hty_id}} = usingUser.store;
load_start()
const {r, d, e, statusCode} = await request({
url: "/api/v1/clazz/find_all_within_date_range",
method: "GET", data: {start_from, end_by, hty_id}
})
load_done()
if (r) {
const nonRepeatable = normalizeClazzListPayload(d.non_repeatable, "merged_non_repeatable");
store.list = nonRepeatable.filter(x => {
if (currentRole === HtyBaseRoles.TEACHER || currentRole === HtySuperRoles.SUPERVISOR) {
return x.teachers?.val?.users?.vals?.some(u => u.user_id === hty_id)
}
if (currentRole === HtyBaseRoles.STUDENT) {
return x.students?.val?.users?.vals?.some(u => u.user_id === hty_id)
}
return false;
});
const repeatable = normalizeClazzListPayload(d.repeatable, "merged_repeatable");
store.repeatList = repeat_prepare(start_from, end_by, repeatable, store.list);
} else {
console.warn("[ClazzQuery] merged query failed", {
e, statusCode, start_from, end_by, hty_id,
});
showFailToast(e);
}
return Promise.resolve(r);
}
async function query_merged_for_subsidiaries(start_from: string, end_by: string, hty_ids: string[]) {
load_start()
await Promise.all(hty_ids.map(async hty_id => {
const {r, d, e, statusCode} = await request({
url: "/api/v1/clazz/find_all_within_date_range",
method: "GET", data: {start_from, end_by, hty_id}
})
if (r) {
const nonRepeatable = normalizeClazzListPayload(d.non_repeatable, `merged_subsidiary_non_repeatable_${hty_id}`);
const list = nonRepeatable.filter(x => x.teachers?.val?.users?.vals?.some(u => u.user_id === hty_id));
const repeatable = normalizeClazzListPayload(d.repeatable, `merged_subsidiary_repeatable_${hty_id}`);
store.subsidiaryClazzByHtyId[hty_id] = {
list,
repeatList: repeat_prepare(start_from, end_by, repeatable, list)
}
} else {
console.warn("[ClazzQuery] merged subsidiary failed", {
e, statusCode, hty_id, start_from, end_by,
});
}
}))
load_done()
}
async function prepareQS(course_sections?: MultiVals<CourseSection>) { async function prepareQS(course_sections?: MultiVals<CourseSection>) {
for (let i = 0; i < (course_sections?.vals?.length || []); i++) { for (let i = 0; i < (course_sections?.vals?.length || []); i++) {
let { resources, ref_resources, id, resource_note_group, ...rest } = course_sections?.vals[i]; let { resources, ref_resources, id, resource_note_group, ...rest } = course_sections?.vals[i];
@@ -506,7 +561,7 @@ export default function useClazz() {
} }
return { return {
store, query, query_repeats, createOrUpdate, removeCurrent, createInstance, notify, setDakasForClazz, query_for_subsidiaries, query_repeats_for_subsidiaries, store, query, query_repeats, query_merged, query_merged_for_subsidiaries, createOrUpdate, removeCurrent, createInstance, notify, setDakasForClazz, query_for_subsidiaries, query_repeats_for_subsidiaries,
find_clazz_attendance_by_clazz_id, batch_save_clazz_attendance, getAuditLog, queryStudentLessons, queryTeacherDetailStats, find_clazz_attendance_by_clazz_id, batch_save_clazz_attendance, getAuditLog, queryStudentLessons, queryTeacherDetailStats,
} }
} }