Compare commits

...

No commits in common. 'main' and 'master' have entirely different histories.
main ... master

@ -0,0 +1,4 @@
# 开发环境
VITE_APP_TITLE=教室智能人脸考勤系统(开发)
VITE_API_BASE_URL=http://10.23.22.43:8088/api
VITE_WS_URL=ws://localhost:8080/ws

@ -0,0 +1,4 @@
# 生产环境
VITE_APP_TITLE=教室智能人脸考勤系统
VITE_API_BASE_URL=https://api.yourschool.com/api
VITE_WS_URL=wss://api.yourschool.com/ws

28
.gitignore vendored

@ -0,0 +1,28 @@
# dependencies
node_modules/
# build output
dist/
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# env
.env
.env.local
.env.*.local
# logs
*.log
npm-debug.log*
# cache
.cache/

@ -0,0 +1,7 @@
# 信任这些特定包的构建脚本
only-built-dependencies[]=@parcel/watcher
only-built-dependencies[]=esbuild
only-built-dependencies[]=vue-demi
# 或者,如果你想允许所有包运行脚本(不推荐,安全性较低)
# ignore-scripts=false

@ -0,0 +1,593 @@
import {
add_location_default,
aim_default,
alarm_clock_default,
apple_default,
arrow_down_bold_default,
arrow_down_default,
arrow_left_bold_default,
arrow_left_default,
arrow_right_bold_default,
arrow_right_default,
arrow_up_bold_default,
arrow_up_default,
avatar_default,
back_default,
baseball_default,
basketball_default,
bell_default,
bell_filled_default,
bicycle_default,
bottom_default,
bottom_left_default,
bottom_right_default,
bowl_default,
box_default,
briefcase_default,
brush_default,
brush_filled_default,
burger_default,
calendar_default,
camera_default,
camera_filled_default,
caret_bottom_default,
caret_left_default,
caret_right_default,
caret_top_default,
cellphone_default,
chat_dot_round_default,
chat_dot_square_default,
chat_line_round_default,
chat_line_square_default,
chat_round_default,
chat_square_default,
check_default,
checked_default,
cherry_default,
chicken_default,
chrome_filled_default,
circle_check_default,
circle_check_filled_default,
circle_close_default,
circle_close_filled_default,
circle_plus_default,
circle_plus_filled_default,
clock_default,
close_bold_default,
close_default,
cloudy_default,
coffee_cup_default,
coffee_default,
coin_default,
cold_drink_default,
collection_default,
collection_tag_default,
comment_default,
compass_default,
connection_default,
coordinate_default,
copy_document_default,
cpu_default,
credit_card_default,
crop_default,
d_arrow_left_default,
d_arrow_right_default,
d_caret_default,
data_analysis_default,
data_board_default,
data_line_default,
delete_default,
delete_filled_default,
delete_location_default,
dessert_default,
discount_default,
dish_default,
dish_dot_default,
document_add_default,
document_checked_default,
document_copy_default,
document_default,
document_delete_default,
document_remove_default,
download_default,
drizzling_default,
edit_default,
edit_pen_default,
eleme_default,
eleme_filled_default,
element_plus_default,
expand_default,
failed_default,
female_default,
files_default,
film_default,
filter_default,
finished_default,
first_aid_kit_default,
flag_default,
fold_default,
folder_add_default,
folder_checked_default,
folder_default,
folder_delete_default,
folder_opened_default,
folder_remove_default,
food_default,
football_default,
fork_spoon_default,
fries_default,
full_screen_default,
goblet_default,
goblet_full_default,
goblet_square_default,
goblet_square_full_default,
gold_medal_default,
goods_default,
goods_filled_default,
grape_default,
grid_default,
guide_default,
handbag_default,
headset_default,
help_default,
help_filled_default,
hide_default,
histogram_default,
home_filled_default,
hot_water_default,
house_default,
ice_cream_default,
ice_cream_round_default,
ice_cream_square_default,
ice_drink_default,
ice_tea_default,
info_filled_default,
iphone_default,
key_default,
knife_fork_default,
lightning_default,
link_default,
list_default,
loading_default,
location_default,
location_filled_default,
location_information_default,
lock_default,
lollipop_default,
magic_stick_default,
magnet_default,
male_default,
management_default,
map_location_default,
medal_default,
memo_default,
menu_default,
message_box_default,
message_default,
mic_default,
microphone_default,
milk_tea_default,
minus_default,
money_default,
monitor_default,
moon_default,
moon_night_default,
more_default,
more_filled_default,
mostly_cloudy_default,
mouse_default,
mug_default,
mute_default,
mute_notification_default,
no_smoking_default,
notebook_default,
notification_default,
odometer_default,
office_building_default,
open_default,
operation_default,
opportunity_default,
orange_default,
paperclip_default,
partly_cloudy_default,
pear_default,
phone_default,
phone_filled_default,
picture_default,
picture_filled_default,
picture_rounded_default,
pie_chart_default,
place_default,
platform_default,
plus_default,
pointer_default,
position_default,
postcard_default,
pouring_default,
present_default,
price_tag_default,
printer_default,
promotion_default,
quartz_watch_default,
question_filled_default,
rank_default,
reading_default,
reading_lamp_default,
refresh_default,
refresh_left_default,
refresh_right_default,
refrigerator_default,
remove_default,
remove_filled_default,
right_default,
scale_to_original_default,
school_default,
scissor_default,
search_default,
select_default,
sell_default,
semi_select_default,
service_default,
set_up_default,
setting_default,
share_default,
ship_default,
shop_default,
shopping_bag_default,
shopping_cart_default,
shopping_cart_full_default,
shopping_trolley_default,
smoking_default,
soccer_default,
sold_out_default,
sort_default,
sort_down_default,
sort_up_default,
stamp_default,
star_default,
star_filled_default,
stopwatch_default,
success_filled_default,
sugar_default,
suitcase_default,
suitcase_line_default,
sunny_default,
sunrise_default,
sunset_default,
switch_button_default,
switch_default,
switch_filled_default,
takeaway_box_default,
ticket_default,
tickets_default,
timer_default,
toilet_paper_default,
tools_default,
top_default,
top_left_default,
top_right_default,
trend_charts_default,
trophy_base_default,
trophy_default,
turn_off_default,
umbrella_default,
unlock_default,
upload_default,
upload_filled_default,
user_default,
user_filled_default,
van_default,
video_camera_default,
video_camera_filled_default,
video_pause_default,
video_play_default,
view_default,
wallet_default,
wallet_filled_default,
warn_triangle_filled_default,
warning_default,
warning_filled_default,
watch_default,
watermelon_default,
wind_power_default,
zoom_in_default,
zoom_out_default
} from "./chunk-ETC2NX3T.js";
import "./chunk-J44PGZX2.js";
import "./chunk-5WRI5ZAA.js";
export {
add_location_default as AddLocation,
aim_default as Aim,
alarm_clock_default as AlarmClock,
apple_default as Apple,
arrow_down_default as ArrowDown,
arrow_down_bold_default as ArrowDownBold,
arrow_left_default as ArrowLeft,
arrow_left_bold_default as ArrowLeftBold,
arrow_right_default as ArrowRight,
arrow_right_bold_default as ArrowRightBold,
arrow_up_default as ArrowUp,
arrow_up_bold_default as ArrowUpBold,
avatar_default as Avatar,
back_default as Back,
baseball_default as Baseball,
basketball_default as Basketball,
bell_default as Bell,
bell_filled_default as BellFilled,
bicycle_default as Bicycle,
bottom_default as Bottom,
bottom_left_default as BottomLeft,
bottom_right_default as BottomRight,
bowl_default as Bowl,
box_default as Box,
briefcase_default as Briefcase,
brush_default as Brush,
brush_filled_default as BrushFilled,
burger_default as Burger,
calendar_default as Calendar,
camera_default as Camera,
camera_filled_default as CameraFilled,
caret_bottom_default as CaretBottom,
caret_left_default as CaretLeft,
caret_right_default as CaretRight,
caret_top_default as CaretTop,
cellphone_default as Cellphone,
chat_dot_round_default as ChatDotRound,
chat_dot_square_default as ChatDotSquare,
chat_line_round_default as ChatLineRound,
chat_line_square_default as ChatLineSquare,
chat_round_default as ChatRound,
chat_square_default as ChatSquare,
check_default as Check,
checked_default as Checked,
cherry_default as Cherry,
chicken_default as Chicken,
chrome_filled_default as ChromeFilled,
circle_check_default as CircleCheck,
circle_check_filled_default as CircleCheckFilled,
circle_close_default as CircleClose,
circle_close_filled_default as CircleCloseFilled,
circle_plus_default as CirclePlus,
circle_plus_filled_default as CirclePlusFilled,
clock_default as Clock,
close_default as Close,
close_bold_default as CloseBold,
cloudy_default as Cloudy,
coffee_default as Coffee,
coffee_cup_default as CoffeeCup,
coin_default as Coin,
cold_drink_default as ColdDrink,
collection_default as Collection,
collection_tag_default as CollectionTag,
comment_default as Comment,
compass_default as Compass,
connection_default as Connection,
coordinate_default as Coordinate,
copy_document_default as CopyDocument,
cpu_default as Cpu,
credit_card_default as CreditCard,
crop_default as Crop,
d_arrow_left_default as DArrowLeft,
d_arrow_right_default as DArrowRight,
d_caret_default as DCaret,
data_analysis_default as DataAnalysis,
data_board_default as DataBoard,
data_line_default as DataLine,
delete_default as Delete,
delete_filled_default as DeleteFilled,
delete_location_default as DeleteLocation,
dessert_default as Dessert,
discount_default as Discount,
dish_default as Dish,
dish_dot_default as DishDot,
document_default as Document,
document_add_default as DocumentAdd,
document_checked_default as DocumentChecked,
document_copy_default as DocumentCopy,
document_delete_default as DocumentDelete,
document_remove_default as DocumentRemove,
download_default as Download,
drizzling_default as Drizzling,
edit_default as Edit,
edit_pen_default as EditPen,
eleme_default as Eleme,
eleme_filled_default as ElemeFilled,
element_plus_default as ElementPlus,
expand_default as Expand,
failed_default as Failed,
female_default as Female,
files_default as Files,
film_default as Film,
filter_default as Filter,
finished_default as Finished,
first_aid_kit_default as FirstAidKit,
flag_default as Flag,
fold_default as Fold,
folder_default as Folder,
folder_add_default as FolderAdd,
folder_checked_default as FolderChecked,
folder_delete_default as FolderDelete,
folder_opened_default as FolderOpened,
folder_remove_default as FolderRemove,
food_default as Food,
football_default as Football,
fork_spoon_default as ForkSpoon,
fries_default as Fries,
full_screen_default as FullScreen,
goblet_default as Goblet,
goblet_full_default as GobletFull,
goblet_square_default as GobletSquare,
goblet_square_full_default as GobletSquareFull,
gold_medal_default as GoldMedal,
goods_default as Goods,
goods_filled_default as GoodsFilled,
grape_default as Grape,
grid_default as Grid,
guide_default as Guide,
handbag_default as Handbag,
headset_default as Headset,
help_default as Help,
help_filled_default as HelpFilled,
hide_default as Hide,
histogram_default as Histogram,
home_filled_default as HomeFilled,
hot_water_default as HotWater,
house_default as House,
ice_cream_default as IceCream,
ice_cream_round_default as IceCreamRound,
ice_cream_square_default as IceCreamSquare,
ice_drink_default as IceDrink,
ice_tea_default as IceTea,
info_filled_default as InfoFilled,
iphone_default as Iphone,
key_default as Key,
knife_fork_default as KnifeFork,
lightning_default as Lightning,
link_default as Link,
list_default as List,
loading_default as Loading,
location_default as Location,
location_filled_default as LocationFilled,
location_information_default as LocationInformation,
lock_default as Lock,
lollipop_default as Lollipop,
magic_stick_default as MagicStick,
magnet_default as Magnet,
male_default as Male,
management_default as Management,
map_location_default as MapLocation,
medal_default as Medal,
memo_default as Memo,
menu_default as Menu,
message_default as Message,
message_box_default as MessageBox,
mic_default as Mic,
microphone_default as Microphone,
milk_tea_default as MilkTea,
minus_default as Minus,
money_default as Money,
monitor_default as Monitor,
moon_default as Moon,
moon_night_default as MoonNight,
more_default as More,
more_filled_default as MoreFilled,
mostly_cloudy_default as MostlyCloudy,
mouse_default as Mouse,
mug_default as Mug,
mute_default as Mute,
mute_notification_default as MuteNotification,
no_smoking_default as NoSmoking,
notebook_default as Notebook,
notification_default as Notification,
odometer_default as Odometer,
office_building_default as OfficeBuilding,
open_default as Open,
operation_default as Operation,
opportunity_default as Opportunity,
orange_default as Orange,
paperclip_default as Paperclip,
partly_cloudy_default as PartlyCloudy,
pear_default as Pear,
phone_default as Phone,
phone_filled_default as PhoneFilled,
picture_default as Picture,
picture_filled_default as PictureFilled,
picture_rounded_default as PictureRounded,
pie_chart_default as PieChart,
place_default as Place,
platform_default as Platform,
plus_default as Plus,
pointer_default as Pointer,
position_default as Position,
postcard_default as Postcard,
pouring_default as Pouring,
present_default as Present,
price_tag_default as PriceTag,
printer_default as Printer,
promotion_default as Promotion,
quartz_watch_default as QuartzWatch,
question_filled_default as QuestionFilled,
rank_default as Rank,
reading_default as Reading,
reading_lamp_default as ReadingLamp,
refresh_default as Refresh,
refresh_left_default as RefreshLeft,
refresh_right_default as RefreshRight,
refrigerator_default as Refrigerator,
remove_default as Remove,
remove_filled_default as RemoveFilled,
right_default as Right,
scale_to_original_default as ScaleToOriginal,
school_default as School,
scissor_default as Scissor,
search_default as Search,
select_default as Select,
sell_default as Sell,
semi_select_default as SemiSelect,
service_default as Service,
set_up_default as SetUp,
setting_default as Setting,
share_default as Share,
ship_default as Ship,
shop_default as Shop,
shopping_bag_default as ShoppingBag,
shopping_cart_default as ShoppingCart,
shopping_cart_full_default as ShoppingCartFull,
shopping_trolley_default as ShoppingTrolley,
smoking_default as Smoking,
soccer_default as Soccer,
sold_out_default as SoldOut,
sort_default as Sort,
sort_down_default as SortDown,
sort_up_default as SortUp,
stamp_default as Stamp,
star_default as Star,
star_filled_default as StarFilled,
stopwatch_default as Stopwatch,
success_filled_default as SuccessFilled,
sugar_default as Sugar,
suitcase_default as Suitcase,
suitcase_line_default as SuitcaseLine,
sunny_default as Sunny,
sunrise_default as Sunrise,
sunset_default as Sunset,
switch_default as Switch,
switch_button_default as SwitchButton,
switch_filled_default as SwitchFilled,
takeaway_box_default as TakeawayBox,
ticket_default as Ticket,
tickets_default as Tickets,
timer_default as Timer,
toilet_paper_default as ToiletPaper,
tools_default as Tools,
top_default as Top,
top_left_default as TopLeft,
top_right_default as TopRight,
trend_charts_default as TrendCharts,
trophy_default as Trophy,
trophy_base_default as TrophyBase,
turn_off_default as TurnOff,
umbrella_default as Umbrella,
unlock_default as Unlock,
upload_default as Upload,
upload_filled_default as UploadFilled,
user_default as User,
user_filled_default as UserFilled,
van_default as Van,
video_camera_default as VideoCamera,
video_camera_filled_default as VideoCameraFilled,
video_pause_default as VideoPause,
video_play_default as VideoPlay,
view_default as View,
wallet_default as Wallet,
wallet_filled_default as WalletFilled,
warn_triangle_filled_default as WarnTriangleFilled,
warning_default as Warning,
warning_filled_default as WarningFilled,
watch_default as Watch,
watermelon_default as Watermelon,
wind_power_default as WindPower,
zoom_in_default as ZoomIn,
zoom_out_default as ZoomOut
};
//# sourceMappingURL=@element-plus_icons-vue.js.map

@ -0,0 +1,7 @@
{
"version": 3,
"sources": [],
"sourcesContent": [],
"mappings": "",
"names": []
}

@ -0,0 +1,58 @@
{
"hash": "6565f0a1",
"configHash": "eb1835b8",
"lockfileHash": "9f1499ac",
"browserHash": "7b79acab",
"optimized": {
"vue": {
"src": "../../node_modules/vue/dist/vue.runtime.esm-bundler.js",
"file": "vue.js",
"fileHash": "4a20bd0d",
"needsInterop": false
},
"pinia": {
"src": "../../node_modules/pinia/dist/pinia.mjs",
"file": "pinia.js",
"fileHash": "e3724164",
"needsInterop": false
},
"element-plus": {
"src": "../../node_modules/element-plus/es/index.mjs",
"file": "element-plus.js",
"fileHash": "6327058f",
"needsInterop": false
},
"element-plus/dist/locale/zh-cn.mjs": {
"src": "../../node_modules/element-plus/dist/locale/zh-cn.mjs",
"file": "element-plus_dist_locale_zh-cn__mjs.js",
"fileHash": "085f6406",
"needsInterop": false
},
"@element-plus/icons-vue": {
"src": "../../node_modules/@element-plus/icons-vue/dist/index.js",
"file": "@element-plus_icons-vue.js",
"fileHash": "88f96e20",
"needsInterop": false
},
"vue-router": {
"src": "../../node_modules/vue-router/dist/vue-router.mjs",
"file": "vue-router.js",
"fileHash": "3270b841",
"needsInterop": false
}
},
"chunks": {
"chunk-ETC2NX3T": {
"file": "chunk-ETC2NX3T.js"
},
"chunk-AYVSL3LM": {
"file": "chunk-AYVSL3LM.js"
},
"chunk-J44PGZX2": {
"file": "chunk-J44PGZX2.js"
},
"chunk-5WRI5ZAA": {
"file": "chunk-5WRI5ZAA.js"
}
}
}

@ -0,0 +1,31 @@
var __create = Object.create;
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __getProtoOf = Object.getPrototypeOf;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __commonJS = (cb, mod) => function __require() {
return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
// If the importer is in node compatibility mode or this is not an ESM
// file that has been converted to a CommonJS file using a Babel-
// compatible transform (i.e. "__esModule" has not been set), then set
// "default" to the CommonJS "module.exports" for node compatibility.
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
mod
));
export {
__commonJS,
__toESM
};
//# sourceMappingURL=chunk-5WRI5ZAA.js.map

@ -0,0 +1,7 @@
{
"version": 3,
"sources": [],
"sourcesContent": [],
"mappings": "",
"names": []
}

@ -0,0 +1,162 @@
// node_modules/@vue/devtools-api/lib/esm/env.js
function getDevtoolsGlobalHook() {
return getTarget().__VUE_DEVTOOLS_GLOBAL_HOOK__;
}
function getTarget() {
return typeof navigator !== "undefined" && typeof window !== "undefined" ? window : typeof globalThis !== "undefined" ? globalThis : {};
}
var isProxyAvailable = typeof Proxy === "function";
// node_modules/@vue/devtools-api/lib/esm/const.js
var HOOK_SETUP = "devtools-plugin:setup";
var HOOK_PLUGIN_SETTINGS_SET = "plugin:settings:set";
// node_modules/@vue/devtools-api/lib/esm/time.js
var supported;
var perf;
function isPerformanceSupported() {
var _a;
if (supported !== void 0) {
return supported;
}
if (typeof window !== "undefined" && window.performance) {
supported = true;
perf = window.performance;
} else if (typeof globalThis !== "undefined" && ((_a = globalThis.perf_hooks) === null || _a === void 0 ? void 0 : _a.performance)) {
supported = true;
perf = globalThis.perf_hooks.performance;
} else {
supported = false;
}
return supported;
}
function now() {
return isPerformanceSupported() ? perf.now() : Date.now();
}
// node_modules/@vue/devtools-api/lib/esm/proxy.js
var ApiProxy = class {
constructor(plugin, hook) {
this.target = null;
this.targetQueue = [];
this.onQueue = [];
this.plugin = plugin;
this.hook = hook;
const defaultSettings = {};
if (plugin.settings) {
for (const id in plugin.settings) {
const item = plugin.settings[id];
defaultSettings[id] = item.defaultValue;
}
}
const localSettingsSaveId = `__vue-devtools-plugin-settings__${plugin.id}`;
let currentSettings = Object.assign({}, defaultSettings);
try {
const raw = localStorage.getItem(localSettingsSaveId);
const data = JSON.parse(raw);
Object.assign(currentSettings, data);
} catch (e) {
}
this.fallbacks = {
getSettings() {
return currentSettings;
},
setSettings(value) {
try {
localStorage.setItem(localSettingsSaveId, JSON.stringify(value));
} catch (e) {
}
currentSettings = value;
},
now() {
return now();
}
};
if (hook) {
hook.on(HOOK_PLUGIN_SETTINGS_SET, (pluginId, value) => {
if (pluginId === this.plugin.id) {
this.fallbacks.setSettings(value);
}
});
}
this.proxiedOn = new Proxy({}, {
get: (_target, prop) => {
if (this.target) {
return this.target.on[prop];
} else {
return (...args) => {
this.onQueue.push({
method: prop,
args
});
};
}
}
});
this.proxiedTarget = new Proxy({}, {
get: (_target, prop) => {
if (this.target) {
return this.target[prop];
} else if (prop === "on") {
return this.proxiedOn;
} else if (Object.keys(this.fallbacks).includes(prop)) {
return (...args) => {
this.targetQueue.push({
method: prop,
args,
resolve: () => {
}
});
return this.fallbacks[prop](...args);
};
} else {
return (...args) => {
return new Promise((resolve) => {
this.targetQueue.push({
method: prop,
args,
resolve
});
});
};
}
}
});
}
async setRealTarget(target) {
this.target = target;
for (const item of this.onQueue) {
this.target.on[item.method](...item.args);
}
for (const item of this.targetQueue) {
item.resolve(await this.target[item.method](...item.args));
}
}
};
// node_modules/@vue/devtools-api/lib/esm/index.js
function setupDevtoolsPlugin(pluginDescriptor, setupFn) {
const descriptor = pluginDescriptor;
const target = getTarget();
const hook = getDevtoolsGlobalHook();
const enableProxy = isProxyAvailable && descriptor.enableEarlyProxy;
if (hook && (target.__VUE_DEVTOOLS_PLUGIN_API_AVAILABLE__ || !enableProxy)) {
hook.emit(HOOK_SETUP, pluginDescriptor, setupFn);
} else {
const proxy = enableProxy ? new ApiProxy(descriptor, hook) : null;
const list = target.__VUE_DEVTOOLS_PLUGINS__ = target.__VUE_DEVTOOLS_PLUGINS__ || [];
list.push({
pluginDescriptor: descriptor,
setupFn,
proxy
});
if (proxy) {
setupFn(proxy.proxiedTarget);
}
}
}
export {
setupDevtoolsPlugin
};
//# sourceMappingURL=chunk-AYVSL3LM.js.map

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

@ -0,0 +1,192 @@
import "./chunk-5WRI5ZAA.js";
// node_modules/element-plus/dist/locale/zh-cn.mjs
var zh_cn_default = {
name: "zh-cn",
el: {
breadcrumb: { label: "面包屑" },
colorpicker: {
confirm: "确定",
clear: "清空",
defaultLabel: "颜色选择器",
description: "当前颜色 {color},按 Enter 键选择新颜色",
alphaLabel: "选择透明度的值",
alphaDescription: "透明度 {alpha}, 当前颜色 {color}",
hueLabel: "选择色相值",
hueDescription: "色相 {hue}, 当前颜色 {color}",
svLabel: "选择饱和度与明度的值",
svDescription: "饱和度 {saturation}, 明度 {brightness}, 当前颜色 {color}",
predefineDescription: "选择 {value} 作为颜色"
},
datepicker: {
now: "此刻",
today: "今天",
cancel: "取消",
clear: "清空",
confirm: "确定",
dateTablePrompt: "使用方向键与 Enter 键可选择日期",
monthTablePrompt: "使用方向键与 Enter 键可选择月份",
yearTablePrompt: "使用方向键与 Enter 键可选择年份",
selectedDate: "已选日期",
selectDate: "选择日期",
selectTime: "选择时间",
startDate: "开始日期",
startTime: "开始时间",
endDate: "结束日期",
endTime: "结束时间",
prevYear: "前一年",
nextYear: "后一年",
prevMonth: "上个月",
nextMonth: "下个月",
year: "年",
month1: "1 月",
month2: "2 月",
month3: "3 月",
month4: "4 月",
month5: "5 月",
month6: "6 月",
month7: "7 月",
month8: "8 月",
month9: "9 月",
month10: "10 月",
month11: "11 月",
month12: "12 月",
weeks: {
sun: "日",
mon: "一",
tue: "二",
wed: "三",
thu: "四",
fri: "五",
sat: "六"
},
weeksFull: {
sun: "星期日",
mon: "星期一",
tue: "星期二",
wed: "星期三",
thu: "星期四",
fri: "星期五",
sat: "星期六"
},
months: {
jan: "一月",
feb: "二月",
mar: "三月",
apr: "四月",
may: "五月",
jun: "六月",
jul: "七月",
aug: "八月",
sep: "九月",
oct: "十月",
nov: "十一月",
dec: "十二月"
}
},
inputNumber: {
decrease: "减少数值",
increase: "增加数值"
},
select: {
loading: "加载中",
noMatch: "无匹配数据",
noData: "无数据",
placeholder: "请选择"
},
mention: { loading: "加载中" },
dropdown: { toggleDropdown: "切换下拉选项" },
cascader: {
noMatch: "无匹配数据",
loading: "加载中",
placeholder: "请选择",
noData: "暂无数据"
},
pagination: {
goto: "前往",
pagesize: "条/页",
total: "共 {total} 条",
pageClassifier: "页",
page: "页",
prev: "上一页",
next: "下一页",
currentPage: "第 {pager} 页",
prevPages: "向前 {pager} 页",
nextPages: "向后 {pager} 页",
deprecationWarning: "你使用了一些已被废弃的用法,请参考 el-pagination 的官方文档"
},
dialog: { close: "关闭此对话框" },
drawer: { close: "关闭此对话框" },
messagebox: {
title: "提示",
confirm: "确定",
cancel: "取消",
error: "输入的数据不合法!",
close: "关闭此对话框"
},
upload: {
deleteTip: "按 Delete 键可删除",
delete: "删除",
preview: "查看图片",
continue: "继续上传"
},
slider: {
defaultLabel: "滑块介于 {min} 至 {max}",
defaultRangeStartLabel: "选择起始值",
defaultRangeEndLabel: "选择结束值"
},
table: {
emptyText: "暂无数据",
confirmFilter: "筛选",
resetFilter: "重置",
clearFilter: "全部",
sumText: "合计",
selectAllLabel: "选择所有行",
selectRowLabel: "选择当前行",
expandRowLabel: "展开当前行",
collapseRowLabel: "收起当前行",
sortLabel: "按 {column} 排序",
filterLabel: "按 {column} 过滤"
},
tag: { close: "关闭此标签" },
tour: {
next: "下一步",
previous: "上一步",
finish: "结束导览",
close: "关闭此对话框"
},
tree: { emptyText: "暂无数据" },
transfer: {
noMatch: "无匹配数据",
noData: "无数据",
titles: ["列表 1", "列表 2"],
filterPlaceholder: "请输入搜索内容",
noCheckedFormat: "共 {total} 项",
hasCheckedFormat: "已选 {checked}/{total} 项"
},
image: { error: "加载失败" },
pageHeader: { title: "返回" },
popconfirm: {
confirmButtonText: "确定",
cancelButtonText: "取消"
},
carousel: {
leftArrow: "上一张幻灯片",
rightArrow: "下一张幻灯片",
indicator: "幻灯片切换至索引 {index}"
},
inputOTP: {
groupLabel: "一次性密码输入框",
defaultLabel: "请输入第 {index} 位 OTP 字符"
}
}
};
export {
zh_cn_default as default
};
/*! Bundled license information:
element-plus/dist/locale/zh-cn.mjs:
(*! Element Plus v2.14.1 *)
*/
//# sourceMappingURL=element-plus_dist_locale_zh-cn__mjs.js.map

File diff suppressed because one or more lines are too long

@ -0,0 +1,3 @@
{
"type": "module"
}

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

@ -0,0 +1,348 @@
import {
BaseTransition,
BaseTransitionPropsValidators,
Comment,
DeprecationTypes,
EffectScope,
ErrorCodes,
ErrorTypeStrings,
Fragment,
KeepAlive,
ReactiveEffect,
Static,
Suspense,
Teleport,
Text,
TrackOpTypes,
Transition,
TransitionGroup,
TriggerOpTypes,
VueElement,
assertNumber,
callWithAsyncErrorHandling,
callWithErrorHandling,
camelize,
capitalize,
cloneVNode,
compatUtils,
compile,
computed,
createApp,
createBaseVNode,
createBlock,
createCommentVNode,
createElementBlock,
createHydrationRenderer,
createPropsRestProxy,
createRenderer,
createSSRApp,
createSlots,
createStaticVNode,
createTextVNode,
createVNode,
customRef,
defineAsyncComponent,
defineComponent,
defineCustomElement,
defineEmits,
defineExpose,
defineModel,
defineOptions,
defineProps,
defineSSRCustomElement,
defineSlots,
devtools,
effect,
effectScope,
getCurrentInstance,
getCurrentScope,
getCurrentWatcher,
getTransitionRawChildren,
guardReactiveProps,
h,
handleError,
hasInjectionContext,
hydrate,
hydrateOnIdle,
hydrateOnInteraction,
hydrateOnMediaQuery,
hydrateOnVisible,
initCustomFormatter,
initDirectivesForSSR,
inject,
isMemoSame,
isProxy,
isReactive,
isReadonly,
isRef,
isRuntimeOnly,
isShallow,
isVNode,
markRaw,
mergeDefaults,
mergeModels,
mergeProps,
nextTick,
nodeOps,
normalizeClass,
normalizeProps,
normalizeStyle,
onActivated,
onBeforeMount,
onBeforeUnmount,
onBeforeUpdate,
onDeactivated,
onErrorCaptured,
onMounted,
onRenderTracked,
onRenderTriggered,
onScopeDispose,
onServerPrefetch,
onUnmounted,
onUpdated,
onWatcherCleanup,
openBlock,
patchProp,
popScopeId,
provide,
proxyRefs,
pushScopeId,
queuePostFlushCb,
reactive,
readonly,
ref,
registerRuntimeCompiler,
render,
renderList,
renderSlot,
resolveComponent,
resolveDirective,
resolveDynamicComponent,
resolveFilter,
resolveTransitionHooks,
setBlockTracking,
setDevtoolsHook,
setTransitionHooks,
shallowReactive,
shallowReadonly,
shallowRef,
ssrContextKey,
ssrUtils,
stop,
toDisplayString,
toHandlerKey,
toHandlers,
toRaw,
toRef,
toRefs,
toValue,
transformVNodeArgs,
triggerRef,
unref,
useAttrs,
useCssModule,
useCssVars,
useHost,
useId,
useModel,
useSSRContext,
useShadowRoot,
useSlots,
useTemplateRef,
useTransitionState,
vModelCheckbox,
vModelDynamic,
vModelRadio,
vModelSelect,
vModelText,
vShow,
version,
warn,
watch,
watchEffect,
watchPostEffect,
watchSyncEffect,
withAsyncContext,
withCtx,
withDefaults,
withDirectives,
withKeys,
withMemo,
withModifiers,
withScopeId
} from "./chunk-J44PGZX2.js";
import "./chunk-5WRI5ZAA.js";
export {
BaseTransition,
BaseTransitionPropsValidators,
Comment,
DeprecationTypes,
EffectScope,
ErrorCodes,
ErrorTypeStrings,
Fragment,
KeepAlive,
ReactiveEffect,
Static,
Suspense,
Teleport,
Text,
TrackOpTypes,
Transition,
TransitionGroup,
TriggerOpTypes,
VueElement,
assertNumber,
callWithAsyncErrorHandling,
callWithErrorHandling,
camelize,
capitalize,
cloneVNode,
compatUtils,
compile,
computed,
createApp,
createBlock,
createCommentVNode,
createElementBlock,
createBaseVNode as createElementVNode,
createHydrationRenderer,
createPropsRestProxy,
createRenderer,
createSSRApp,
createSlots,
createStaticVNode,
createTextVNode,
createVNode,
customRef,
defineAsyncComponent,
defineComponent,
defineCustomElement,
defineEmits,
defineExpose,
defineModel,
defineOptions,
defineProps,
defineSSRCustomElement,
defineSlots,
devtools,
effect,
effectScope,
getCurrentInstance,
getCurrentScope,
getCurrentWatcher,
getTransitionRawChildren,
guardReactiveProps,
h,
handleError,
hasInjectionContext,
hydrate,
hydrateOnIdle,
hydrateOnInteraction,
hydrateOnMediaQuery,
hydrateOnVisible,
initCustomFormatter,
initDirectivesForSSR,
inject,
isMemoSame,
isProxy,
isReactive,
isReadonly,
isRef,
isRuntimeOnly,
isShallow,
isVNode,
markRaw,
mergeDefaults,
mergeModels,
mergeProps,
nextTick,
nodeOps,
normalizeClass,
normalizeProps,
normalizeStyle,
onActivated,
onBeforeMount,
onBeforeUnmount,
onBeforeUpdate,
onDeactivated,
onErrorCaptured,
onMounted,
onRenderTracked,
onRenderTriggered,
onScopeDispose,
onServerPrefetch,
onUnmounted,
onUpdated,
onWatcherCleanup,
openBlock,
patchProp,
popScopeId,
provide,
proxyRefs,
pushScopeId,
queuePostFlushCb,
reactive,
readonly,
ref,
registerRuntimeCompiler,
render,
renderList,
renderSlot,
resolveComponent,
resolveDirective,
resolveDynamicComponent,
resolveFilter,
resolveTransitionHooks,
setBlockTracking,
setDevtoolsHook,
setTransitionHooks,
shallowReactive,
shallowReadonly,
shallowRef,
ssrContextKey,
ssrUtils,
stop,
toDisplayString,
toHandlerKey,
toHandlers,
toRaw,
toRef,
toRefs,
toValue,
transformVNodeArgs,
triggerRef,
unref,
useAttrs,
useCssModule,
useCssVars,
useHost,
useId,
useModel,
useSSRContext,
useShadowRoot,
useSlots,
useTemplateRef,
useTransitionState,
vModelCheckbox,
vModelDynamic,
vModelRadio,
vModelSelect,
vModelText,
vShow,
version,
warn,
watch,
watchEffect,
watchPostEffect,
watchSyncEffect,
withAsyncContext,
withCtx,
withDefaults,
withDirectives,
withKeys,
withMemo,
withModifiers,
withScopeId
};
//# sourceMappingURL=vue.js.map

@ -0,0 +1,7 @@
{
"version": 3,
"sources": [],
"sourcesContent": [],
"mappings": "",
"names": []
}

@ -1,2 +0,0 @@
# attendanceSystem

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>教室智能人脸考勤系统</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

@ -0,0 +1,27 @@
{
"name": "attendance-system",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.1",
"axios": "^1.6.8",
"echarts": "^5.5.0",
"element-plus": "^2.6.2",
"pinia": "^2.1.7",
"pinia-plugin-persistedstate": "^3.2.3",
"vue": "^3.4.21",
"vue-router": "^4.3.0",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.4",
"sass": "^1.72.0",
"vite": "^5.2.0"
}
}

File diff suppressed because it is too large Load Diff

@ -0,0 +1,4 @@
allowBuilds:
'@parcel/watcher': false
esbuild: false
vue-demi: false

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

@ -0,0 +1,10 @@
<template>
<router-view />
</template>
<script setup>
</script>
<style lang="scss">
@use '@/styles/variables.scss' as *;
</style>

@ -0,0 +1,21 @@
import request from '@/utils/request'
/** 分页查询考勤记录 */
export const getRecordPage = (params) => {
return request.get('/attendance/task/page', { params })
}
/** 分页查询考勤详情 */
export const getDetailPage = (params) => {
return request.get('/attendance/detail/page', { params })
}
/** 获取教室当前考勤数据 */
export const getCurrentAttendance = (id, currentTime) => {
return request.get(`/classroom/${id}/current-attendance`, { params: { currentTime } })
}
/** 更新考勤详情状态 */
export const updateDetailStatus = (id, attStatus) => {
return request.put(`/attendance/detail/${id}`, { attStatus })
}

@ -0,0 +1,11 @@
import request from '@/utils/request'
/** 登录 */
export const login = (params) => {
return request.post('/auth/login', params)
}
/** 退出登录 */
export const logout = () => {
return request.post('/auth/logout')
}

@ -0,0 +1,21 @@
import request from '@/utils/request'
/** 获取行为类型统计(含占比数据) */
export const getBehaviorTypesWithStats = (params) => {
return request.get('/behavior/types-with-stats', { params })
}
/** 获取各行为时段分布数据 */
export const getBehaviorTimePeriod = (params) => {
return request.get('/behavior/stats/time-period', { params })
}
/** 获取行为标记图片记录(分页) */
export const getBehaviorRecords = (params) => {
return request.get('/behavior/records', { params })
}
/** 获取行为类型列表 */
export const getBehaviorTypes = () => {
return request.get('/behavior/types')
}

@ -0,0 +1,16 @@
import request from '@/utils/request'
/** 获取大屏核心统计数据 */
export const getBigScreenStats = () => {
return request.get('/bigscreen/stats')
}
/** 获取出勤趋势数据 */
export const getBigScreenTrend = () => {
return request.get('/bigscreen/trend')
}
/** 获取课堂行为分布数据 */
export const getBigScreenBehavior = () => {
return request.get('/bigscreen/behavior-distribution')
}

@ -0,0 +1,16 @@
import request from '@/utils/request'
/** 获取首页核心统计数据 */
export const getStats = () => {
return request.get('/dashboard/stats')
}
/** 获取近7天出勤趋势 */
export const getTrend = () => {
return request.get('/dashboard/trend')
}
/** 获取各班级出勤率排名 */
export const getRanking = (params) => {
return request.get('/dashboard/ranking', { params })
}

@ -0,0 +1,6 @@
import request from '@/utils/request'
/** 获取考勤历史记录(分页) */
export const getAttendanceHistory = (params) => {
return request.get('/attendance/history', { params })
}

@ -0,0 +1,207 @@
import request from '@/utils/request'
// ==================== 教学楼信息 ====================
// 获取教学楼列表(下拉用,返回全部)
export function getBuildingList() {
return request({ url: '/building/list', method: 'get' })
}
// 获取教学楼列表(分页)
export function getBuildings(params) {
return request({ url: '/building/page', method: 'get', params })
}
// 新增教学楼
export function addBuilding(data) {
return request({ url: '/building', method: 'post', data })
}
// 编辑教学楼
export function updateBuilding(data) {
return request({ url: `/building/${data.id}`, method: 'put', data })
}
// 删除教学楼(支持批量,传入 id 数组)
export function deleteBuilding(ids) {
return request({ url: '/building', method: 'delete', data: ids })
}
// 获取教室列表(下拉用,返回全部)
export function getRoomsList() {
return request({ url: '/classroom/list', method: 'get' })
}
// 获取教室列表分页按教学楼id
export function getRooms(params) {
return request({ url: '/classroom/page', method: 'get', params })
}
// 新增教室
export function addRoom(data) {
return request({ url: '/classroom', method: 'post', data })
}
// 编辑教室
export function updateRoom(data) {
return request({ url: `/classroom/${data.id}`, method: 'put', data })
}
// 删除教室(支持批量,传入 id 数组)
export function deleteRoom(ids) {
return request({ url: '/classroom', method: 'delete', data: ids })
}
// 获取摄像头列表(下拉用,返回全部)
export function getDeviceList() {
return request({ url: '/device/list', method: 'get' })
}
// 获取摄像头列表按教室id
export function getCameras(params) {
return request({ url: '/device/page', method: 'get', params })
}
// 新增摄像头
export function addCamera(data) {
return request({ url: '/device', method: 'post', data })
}
// 编辑摄像头
export function updateCamera(data) {
return request({ url: `/device/${data.id}`, method: 'put', data })
}
// 删除摄像头(支持批量,传入 id 数组)
export function deleteCamera(ids) {
return request({ url: '/device', method: 'delete', data: ids })
}
// ==================== 班级信息 ====================
// 获取班级列表(下拉用,返回全部)
export function getClassList() {
return request({ url: '/class/list', method: 'get' })
}
// 获取班级列表(分页)
export function getClasses(params) {
return request({ url: '/class/page', method: 'get', params })
}
// 新增班级
export function addClass(data) {
return request({ url: '/class', method: 'post', data })
}
// 编辑班级
export function updateClass(data) {
return request({ url: `/class/${data.id}`, method: 'put', data })
}
// 删除班级(支持批量,传入 id 数组)
export function deleteClass(ids) {
return request({ url: '/class', method: 'delete', data: ids })
}
// ==================== 学生信息 ====================
// 获取学生列表(分页 + 关键字搜索)
export function getStudentPage(params) {
return request({ url: '/student/page', method: 'get', params })
}
// 新增学生
export function addStudent(data) {
return request({ url: '/student', method: 'post', data })
}
// 编辑学生
export function updateStudent(data) {
return request({ url: `/student/${data.id}`, method: 'put', data })
}
// 删除学生(支持批量,传入 id 数组)
export function deleteStudent(ids) {
return request({ url: '/student', method: 'delete', data: ids })
}
// ==================== 教师信息 ====================
// 获取教师列表(下拉用,返回全部)
export function getTeacherList() {
return request({ url: '/teacher/list', method: 'get' })
}
// 获取教师列表(分页 + 关键字搜索)
export function getTeachers(params) {
return request({ url: '/teacher/page', method: 'get', params })
}
// 新增教师
export function addTeacher(data) {
return request({ url: '/teacher', method: 'post', data })
}
// 编辑教师
export function updateTeacher(data) {
return request({ url: `/teacher/${data.id}`, method: 'put', data })
}
// 获取教师详情
export function getTeacherDetail(id) {
return request({ url: `/teacher/${id}`, method: 'get' })
}
// 删除教师(支持批量,传入 id 数组)
export function deleteTeacher(ids) {
return request({ url: '/teacher', method: 'delete', data: ids })
}
// ==================== 课程信息 ====================
// 获取课程列表(下拉用,返回全部)
export function getCourseList() {
return request({ url: '/course/list', method: 'get' })
}
// 获取课程列表(分页)
export function getCourses(params) {
return request({ url: '/course/page', method: 'get', params })
}
// 新增课程
export function addCourse(data) {
return request({ url: '/course', method: 'post', data })
}
// 编辑课程
export function updateCourse(data) {
return request({ url: `/course/${data.id}`, method: 'put', data })
}
// 删除课程(支持批量,传入 id 数组)
export function deleteCourse(ids) {
return request({ url: '/course', method: 'delete', data: ids })
}
// ==================== 课程安排 ====================
// 获取课程安排列表(分页)
export function getSchedulePage(params) {
return request({ url: '/schedule/page', method: 'get', params })
}
// 新增课程安排
export function addSchedule(data) {
return request({ url: '/schedule', method: 'post', data })
}
// 编辑课程安排
export function updateSchedule(data) {
return request({ url: `/schedule/${data.id}`, method: 'put', data })
}
// 删除课程安排(支持批量,传入 id 数组)
export function deleteSchedule(ids) {
return request({ url: '/schedule', method: 'delete', data: ids })
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

@ -0,0 +1,133 @@
<template>
<div class="data-card fade-in-up" @click="$emit('click')">
<div class="card-header">
<div class="card-header-left">
<div class="card-icon" :style="{ background: iconBg }">
<el-icon :size="20" :color="iconColor"><component :is="icon" /></el-icon>
</div>
<span class="card-label">{{ label }}</span>
</div>
<span v-if="date" class="card-date">{{ date }}</span>
</div>
<div class="card-body">
<div class="card-value">
<span class="value-num">{{ value }}</span>
<span v-if="unit" class="value-unit">{{ unit }}</span>
</div>
<div v-if="trend !== undefined" class="card-trend" :class="trend >= 0 ? 'up' : 'down'">
<el-icon :size="14">
<CaretTop v-if="trend >= 0" />
<CaretBottom v-else />
</el-icon>
<span>{{ Math.abs(trend) }}%</span>
<span class="trend-label">较昨日</span>
</div>
</div>
</div>
</template>
<script setup>
defineProps({
label: { type: String, required: true },
value: { type: [String, Number], required: true },
unit: { type: String, default: '' },
icon: { type: [String, Object], required: true },
iconColor: { type: String, default: '#52c41a' },
iconBg: { type: String, default: '#e8f9e0' },
trend: { type: Number, default: undefined },
date: { type: String, default: '' }
})
defineEmits(['click'])
</script>
<style lang="scss" scoped>
.data-card {
background: #ffffff;
border-radius: 8px;
padding: 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
cursor: pointer;
transition: all 0.3s ease;
border: 1px solid #f0f0f0;
&:hover {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
transform: translateY(-2px);
}
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.card-header-left {
display: flex;
align-items: center;
gap: 10px;
}
.card-icon {
width: 40px;
height: 40px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
}
.card-label {
font-size: 14px;
color: #525252;
}
.card-date {
font-size: 12px;
color: #bfbfbf;
white-space: nowrap;
}
.card-body {
display: flex;
align-items: flex-end;
justify-content: space-between;
}
.value-num {
font-size: 32px;
font-weight: 700;
color: #262626;
line-height: 1;
}
.value-unit {
font-size: 14px;
color: #525252;
margin-left: 4px;
}
.card-trend {
display: flex;
align-items: center;
font-size: 13px;
font-weight: 500;
gap: 2px;
&.up {
color: #52c41a;
}
&.down {
color: #f5222d;
}
.trend-label {
font-size: 12px;
color: #bfbfbf;
font-weight: 400;
margin-left: 4px;
}
}
</style>

@ -0,0 +1,194 @@
<template>
<aside class="sidebar" :class="{ collapsed: appStore.sidebarCollapsed }">
<!-- Logo 区域 -->
<div class="sidebar-logo">
<div class="logo-icon">
<el-icon :size="28"><Camera /></el-icon>
</div>
<transition name="fade">
<span v-show="!appStore.sidebarCollapsed" class="logo-text"></span>
</transition>
</div>
<!-- 菜单区域 -->
<el-scrollbar class="sidebar-menu">
<el-menu
:default-active="activeMenu"
:collapse="appStore.sidebarCollapsed"
:collapse-transition="false"
:default-openeds="['/info']"
router
background-color="transparent"
text-color="#525252"
active-text-color="#ffffff"
>
<el-menu-item index="/dashboard">
<el-icon><HomeFilled /></el-icon>
<span>首页</span>
</el-menu-item>
<el-menu-item index="/behavior">
<el-icon><TrendCharts /></el-icon>
<span>课堂行为分析</span>
</el-menu-item>
<el-menu-item index="/history">
<el-icon><Search /></el-icon>
<span>历史记录查询</span>
</el-menu-item>
<el-menu-item index="/bigscreen">
<el-icon><DataAnalysis /></el-icon>
<span>数据展示大屏</span>
</el-menu-item>
<el-sub-menu index="/info">
<template #title>
<el-icon><List /></el-icon>
<span>信息管理</span>
</template>
<el-menu-item index="/info/building">
<el-icon><OfficeBuilding /></el-icon>
<span>教室信息</span>
</el-menu-item>
<el-menu-item index="/info/class">
<el-icon><School /></el-icon>
<span>班级信息</span>
</el-menu-item>
<el-menu-item index="/info/teacher">
<el-icon><UserFilled /></el-icon>
<span>教师信息</span>
</el-menu-item>
<el-menu-item index="/info/student">
<el-icon><User /></el-icon>
<span>学生信息</span>
</el-menu-item>
<el-menu-item index="/info/course">
<el-icon><Reading /></el-icon>
<span>课程信息</span>
</el-menu-item>
</el-sub-menu>
<el-sub-menu index="/settings">
<template #title>
<el-icon><Setting /></el-icon>
<span>系统设置</span>
</template>
<el-menu-item index="/settings/rules">
<el-icon><Notebook /></el-icon>
<span>考勤规则设置</span>
</el-menu-item>
<el-menu-item index="/settings/permissions">
<el-icon><Lock /></el-icon>
<span>权限管理</span>
</el-menu-item>
</el-sub-menu>
</el-menu>
</el-scrollbar>
<!-- 底部收起按钮 -->
<div class="sidebar-toggle" @click="appStore.toggleSidebar()">
<el-icon :size="18">
<DArrowLeft v-if="!appStore.sidebarCollapsed" />
<DArrowRight v-else />
</el-icon>
</div>
</aside>
</template>
<script setup>
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { useAppStore } from '@/stores/app'
const route = useRoute()
const appStore = useAppStore()
const activeMenu = computed(() => {
const path = route.path
// /bigscreen/room/:id ""
if (path.startsWith('/bigscreen')) return '/bigscreen'
return path
})
</script>
<style lang="scss" scoped>
.sidebar {
position: fixed;
left: 0;
top: 0;
bottom: 0;
width: 240px;
background: #ffffff;
border-right: 1px solid #f0f0f0;
display: flex;
flex-direction: column;
transition: width 0.3s ease;
z-index: 100;
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.03);
&.collapsed {
width: 64px;
}
}
.sidebar-logo {
height: 56px;
display: flex;
align-items: center;
padding: 0 16px;
gap: 10px;
border-bottom: 1px solid #f0f0f0;
.logo-icon {
width: 32px;
height: 32px;
background: linear-gradient(135deg, #52c41a, #49b018);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
color: #ffffff;
flex-shrink: 0;
}
.logo-text {
font-size: 16px;
font-weight: 700;
color: #262626;
white-space: nowrap;
}
}
.sidebar-menu {
flex: 1;
padding: 8px 0;
overflow-y: auto;
}
.sidebar-toggle {
height: 44px;
display: flex;
align-items: center;
justify-content: center;
border-top: 1px solid #f0f0f0;
cursor: pointer;
color: #525252;
transition: all 0.2s ease;
&:hover {
color: #52c41a;
background: #f0fdf0;
}
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

@ -0,0 +1,72 @@
<template>
<div class="skeleton-container" :class="{ 'is-card': card }">
<div v-if="card" class="skeleton-card">
<div class="skeleton skeleton-circle"></div>
<div class="skeleton skeleton-line skeleton-line-short"></div>
<div class="skeleton skeleton-line skeleton-line-long"></div>
</div>
<div v-else class="skeleton-table">
<div v-for="i in rows" :key="i" class="skeleton-row">
<div v-for="j in cols" :key="j" class="skeleton skeleton-cell"></div>
</div>
</div>
</div>
</template>
<script setup>
defineProps({
card: { type: Boolean, default: false },
rows: { type: Number, default: 5 },
cols: { type: Number, default: 4 }
})
</script>
<style lang="scss" scoped>
.skeleton-container {
padding: 20px;
}
.skeleton-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.skeleton-circle {
width: 48px;
height: 48px;
border-radius: 50%;
}
.skeleton-line {
height: 14px;
border-radius: 4px;
width: 80%;
&.skeleton-line-short {
width: 60%;
}
&.skeleton-line-long {
width: 90%;
}
}
.skeleton-table {
display: flex;
flex-direction: column;
gap: 12px;
}
.skeleton-row {
display: flex;
gap: 16px;
}
.skeleton-cell {
flex: 1;
height: 16px;
border-radius: 4px;
}
</style>

@ -0,0 +1,157 @@
<template>
<header class="navbar">
<div class="navbar-left">
<div class="breadcrumb">
<el-breadcrumb separator="/">
<el-breadcrumb-item :to="{ path: '/dashboard' }">
<el-icon><HomeFilled /></el-icon>
</el-breadcrumb-item>
<el-breadcrumb-item v-if="route.meta.title">
{{ route.meta.title }}
</el-breadcrumb-item>
</el-breadcrumb>
</div>
</div>
<div class="navbar-right">
<!-- 全屏按钮 -->
<el-tooltip
effect="dark"
content="全屏"
placement="bottom"
>
<el-button link @click="toggleFullscreen">
<el-icon :size="20"><FullScreen /></el-icon>
</el-button>
</el-tooltip>
<!-- 用户下拉 -->
<el-dropdown trigger="click" @command="handleCommand">
<div class="user-info">
<el-avatar :size="32" :icon="UserFilled" class="user-avatar" />
<span class="user-name">{{ userStore.name }}</span>
<el-icon class="user-arrow"><ArrowDown /></el-icon>
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="profile">
<el-icon><User /></el-icon>
个人中心
</el-dropdown-item>
<el-dropdown-item command="logout" divided>
<el-icon><SwitchButton /></el-icon>
退出登录
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</header>
</template>
<script setup>
import { ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useUserStore } from '@/stores/user'
import { useAppStore } from '@/stores/app'
import { ElMessage } from 'element-plus'
import { UserFilled } from '@element-plus/icons-vue'
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
const appStore = useAppStore()
const toggleFullscreen = () => {
if (document.fullscreenElement) {
document.exitFullscreen()
} else {
document.documentElement.requestFullscreen()
}
}
const handleCommand = async (cmd) => {
if (cmd === 'logout') {
await userStore.logout()
ElMessage.success('已退出登录')
router.push('/login')
} else if (cmd === 'profile') {
ElMessage.info('个人中心功能开发中')
}
}
</script>
<style lang="scss" scoped>
.navbar {
height: 56px;
background: #ffffff;
border-bottom: 1px solid #f0f0f0;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.02);
z-index: 50;
}
.navbar-left {
display: flex;
align-items: center;
}
.navbar-right {
display: flex;
align-items: center;
gap: 8px;
}
.search-input {
:deep(.el-input__wrapper) {
background: #f5f7fa;
border-radius: 20px;
box-shadow: none;
transition: all 0.2s ease;
&:hover,
&.is-focus {
background: #ffffff;
box-shadow: 0 0 0 1px #52c41a inset;
}
}
}
.header-badge {
margin-right: 4px;
}
.user-info {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
padding: 4px 8px;
border-radius: 8px;
transition: background 0.2s ease;
&:hover {
background: #f5f7fa;
}
.user-avatar {
background: linear-gradient(135deg, #52c41a, #73d13d);
flex-shrink: 0;
}
.user-name {
font-size: 14px;
color: #262626;
font-weight: 500;
}
.user-arrow {
font-size: 12px;
color: #bfbfbf;
transition: transform 0.2s ease;
}
}
</style>

@ -0,0 +1,282 @@
<template>
<div class="webrtc-wrapper">
<video
ref="videoRef"
autoplay
muted
playsinline
class="video-player"
></video>
<!-- 加载状态 -->
<div v-if="loading" class="loading-overlay">
<span class="spinner"></span>
<span>连接中...</span>
</div>
<!-- 录制状态指示器 -->
<div v-if="isRecording" class="recording-indicator">
<span class="dot"></span> 录制中 {{ recordTime }}s
</div>
<div v-if="error" class="error-overlay">{{ error }}</div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted, watch } from 'vue';
const props = defineProps({
src: { type: String, required: true }
});
// src
watch(() => props.src, (newSrc, oldSrc) => {
if (newSrc && newSrc !== oldSrc) {
disconnect();
connect();
}
});
//
const emit = defineEmits(['record-status-change','record-complete','connection-status']);
const videoRef = ref(null);
const error = ref('');
const loading = ref(false);
let pc = null;
// --- ---
const isRecording = ref(false);
const recordTime = ref(0);
let mediaRecorder = null;
let recordedChunks = [];
let timerInterval = null;
// 1. WebRTC
const disconnect = () => {
if (pc) {
pc.close();
pc = null;
}
if (videoRef.value) {
videoRef.value.srcObject = null;
}
error.value = '';
};
// 2. WebRTC
const connect = async () => {
loading.value = true;
error.value = '';
try {
pc = new RTCPeerConnection();
pc.ontrack = (event) => {
if (videoRef.value && event.streams[0]) {
videoRef.value.srcObject = event.streams[0];
emit('connection-status', true);
}
};
pc.addTransceiver('video', { direction: 'recvonly' });
pc.addTransceiver('audio', { direction: 'recvonly' });
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
const res = await fetch(props.src, {
method: 'POST',
headers: { 'Content-Type': 'application/sdp' },
body: pc.localDescription.sdp
});
if (!res.ok) throw new Error(`Status: ${res.status}`);
const answer = await res.text();
await pc.setRemoteDescription({ type: 'answer', sdp: answer });
} catch (e) {
error.value = '连接失败,请检查视频地址';
emit('connection-status', false);
console.error(e);
} finally {
let timeout = setTimeout(() => {
loading.value = false;
clearTimeout(timeout);
}, 1500);
}
};
// 2.
const takeSnapshot = () => {
if (!videoRef.value) return null;
const video = videoRef.value;
const canvas = document.createElement('canvas');
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
const ctx = canvas.getContext('2d');
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
// Blob (: image/png, image/jpeg)
return new Promise((resolve) => {
canvas.toBlob((blob) => {
resolve(blob);
}, 'image/jpeg', 0.9);
});
};
// 3.
const startRecord = () => {
if (!videoRef.value || isRecording.value) return;
try {
// MediaStream
// captureStream API Chrome/Edge/Firefox
const stream = videoRef.value.captureStream ? videoRef.value.captureStream() : videoRef.value.mozCaptureStream();
if (!stream) {
error.value = '浏览器不支持捕获视频流';
return;
}
recordedChunks = [];
// MediaRecorder
// mimeType 'video/webm; codecs=vp9'
mediaRecorder = new MediaRecorder(stream, { mimeType: 'video/webm' });
mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) {
recordedChunks.push(event.data);
}
};
mediaRecorder.onstop = () => {
const blob = new Blob(recordedChunks, { type: 'video/webm' });
recordedChunks = []; //
// blob
emit('record-complete', blob);
console.log('录制完成Blob大小:', blob.size);
};
mediaRecorder.start();
isRecording.value = true;
recordTime.value = 0;
//
timerInterval = setInterval(() => {
recordTime.value++;
}, 1000);
emit('record-status-change', true);
} catch (err) {
console.error('录制启动失败:', err);
error.value = '录制启动失败';
}
};
// 4.
const stopRecord = () => {
if (mediaRecorder && isRecording.value) {
mediaRecorder.stop();
isRecording.value = false;
clearInterval(timerInterval);
emit('record-status-change', false);
}
};
// 5.
const handleRecordComplete = (blob) => {
console.log('录制完成,文件大小:', blob.size, 'bytes');
//
// uploadVideoToServer(blob);
// URL
// const url = URL.createObjectURL(blob);
};
//
const uploadVideoToServer = async (blob) => {
const formData = new FormData();
formData.append('file', blob, `record_${new Date().getTime()}.webm`);
try {
//
// await axios.post('/api/upload/video', formData);
console.log('视频上传成功');
} catch (e) {
console.error('上传失败', e);
}
};
onMounted(() => connect());
onUnmounted(() => {
if (pc) pc.close();
if (isRecording.value) stopRecord();
});
//
defineExpose({
takeSnapshot,
startRecord,
stopRecord
});
</script>
<style scoped>
.webrtc-wrapper { width: 100%; height: 100%; position: relative; background: #000; }
.video-player { width: 100%; height: 100%; object-fit: contain; }
.error-overlay { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); color: white; }
.loading-overlay {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: rgba(255, 255, 255, 0.85);
font-size: 14px;
display: flex;
align-items: center;
gap: 8px;
}
.spinner {
width: 20px;
height: 20px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: #409eff;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.recording-indicator {
position: absolute;
top: 10px;
right: 10px;
background: rgba(0, 0, 0, 0.6);
color: white;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
display: flex;
align-items: center;
gap: 6px;
}
.dot {
width: 8px;
height: 8px;
background-color: red;
border-radius: 50%;
animation: blink 1s infinite;
}
@keyframes blink {
0% { opacity: 1; }
50% { opacity: 0.4; }
100% { opacity: 1; }
}
</style>

@ -0,0 +1,44 @@
import { ref } from 'vue'
import { getCourseList, getTeacherList, getClassList, getRoomsList } from '@/api/info'
const courseMap = ref({})
const teacherMap = ref({})
const classMap = ref({})
const roomMap = ref({})
let loadPromise = null
const loadNameMaps = () => {
if (!loadPromise) {
loadPromise = Promise.all([
getCourseList(),
getTeacherList(),
getClassList(),
getRoomsList()
]).then(([courseRes, teacherRes, classRes, roomRes]) => {
const toMap = (list, nameKey = 'name') => {
const map = {}
;(list || []).forEach((item) => { map[item.id] = item[nameKey] })
return map
}
courseMap.value = toMap(courseRes.data, 'courseName')
teacherMap.value = toMap(teacherRes.data, 'name')
classMap.value = toMap(classRes.data, 'className')
roomMap.value = toMap(roomRes.data, 'roomName')
}).catch((e) => {
// 加载失败保留空映射,重置 promise 以便下次可重试
console.error('[useNameMaps] 加载名称映射失败:', e)
loadPromise = null
})
}
return loadPromise
}
/** 根据 ID 获取名称,兜底返回 ID 本身 */
const courseName = (id) => courseMap.value[id] ?? id ?? ''
const teacherName = (id) => teacherMap.value[id] ?? id ?? ''
const className = (id) => classMap.value[id] ?? id ?? ''
const roomName = (id) => roomMap.value[id] ?? id ?? ''
export function useNameMaps() {
return { loadNameMaps, courseName, teacherName, className, roomName }
}

@ -0,0 +1,71 @@
<template>
<div class="main-layout" :class="{ collapsed: appStore.sidebarCollapsed }">
<!-- 侧边栏 -->
<SideMenu />
<!-- 右侧主体 -->
<div class="main-container">
<TopNavbar />
<div class="main-content">
<router-view v-slot="{ Component }">
<transition name="fade-slide" mode="out-in">
<component :is="Component" />
</transition>
</router-view>
</div>
</div>
</div>
</template>
<script setup>
import { useAppStore } from '@/stores/app'
import SideMenu from '@/components/SideMenu.vue'
import TopNavbar from '@/components/TopNavbar.vue'
const appStore = useAppStore()
</script>
<style lang="scss" scoped>
.main-layout {
display: flex;
height: 100vh;
overflow: hidden;
&.collapsed {
.main-container {
margin-left: 64px;
}
}
}
.main-container {
flex: 1;
margin-left: 240px;
display: flex;
flex-direction: column;
transition: margin-left 0.3s ease;
overflow: hidden;
}
.main-content {
flex: 1;
overflow-y: auto;
background: #f5f7fa;
padding: 20px;
}
//
.fade-slide-enter-active,
.fade-slide-leave-active {
transition: all 0.3s ease;
}
.fade-slide-enter-from {
opacity: 0;
transform: translateX(12px);
}
.fade-slide-leave-to {
opacity: 0;
transform: translateX(-12px);
}
</style>

@ -0,0 +1,27 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
import ElementPlus from 'element-plus'
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
import 'element-plus/dist/index.css'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import App from './App.vue'
import router from './router'
import './styles/global.scss'
import './styles/element-override.scss'
const app = createApp(App)
// 注册所有 element-plus 图标
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)
app.use(pinia)
app.use(router)
app.use(ElementPlus, { locale: zhCn })
app.mount('#app')

@ -0,0 +1,111 @@
import { createRouter, createWebHashHistory } from 'vue-router'
import { useUserStore } from '@/stores/user'
const routes = [
{
path: '/',
redirect: '/dashboard'
},
{
path: '/login',
name: 'Login',
component: () => import('@/views/login/index.vue'),
meta: { title: '登录' }
},
{
path: '/',
component: () => import('@/layouts/MainLayout.vue'),
children: [
{
path: 'dashboard',
name: 'Dashboard',
component: () => import('@/views/dashboard/index.vue'),
meta: { title: '首页', icon: 'HomeFilled' }
},
{
path: 'behavior',
name: 'Behavior',
component: () => import('@/views/behavior/index.vue'),
meta: { title: '课堂行为分析', icon: 'TrendCharts' }
},
{
path: 'history',
name: 'History',
component: () => import('@/views/history/index.vue'),
meta: { title: '历史记录查询', icon: 'Search' }
},
{
path: 'bigscreen',
name: 'BigScreen',
component: () => import('@/views/bigscreen/index.vue'),
meta: { title: '数据展示大屏', icon: 'DataAnalysis' }
},
{
path: 'settings/rules',
name: 'Rules',
component: () => import('@/views/settings/rules.vue'),
meta: { title: '考勤规则设置', icon: 'Setting' }
},
{
path: 'settings/permissions',
name: 'Permissions',
component: () => import('@/views/settings/permissions.vue'),
meta: { title: '权限管理', icon: 'Lock' }
},
{
path: 'info/student',
name: 'InfoStudent',
component: () => import('@/views/info/student.vue'),
meta: { title: '学生信息', icon: 'User' }
},
{
path: 'info/building',
name: 'InfoBuilding',
component: () => import('@/views/info/building.vue'),
meta: { title: '教室信息', icon: 'OfficeBuilding' }
},
{
path: 'info/class',
name: 'InfoClass',
component: () => import('@/views/info/class.vue'),
meta: { title: '班级信息', icon: 'School' }
},
{
path: 'info/teacher',
name: 'InfoTeacher',
component: () => import('@/views/info/teacher.vue'),
meta: { title: '教师信息', icon: 'UserFilled' }
},
{
path: 'info/course',
name: 'InfoCourse',
component: () => import('@/views/info/course.vue'),
meta: { title: '课程信息', icon: 'Reading' }
},
{
path: 'bigscreen/room/:id',
name: 'BigScreenRoomDetail',
component: () => import('@/views/bigscreen/roomDetail.vue'),
meta: { title: '教室监控详情' }
}
]
}
]
const router = createRouter({
history: createWebHashHistory(),
routes
})
router.beforeEach((to, from, next) => {
document.title = to.meta.title ? `${to.meta.title} - 教室智能人脸考勤系统` : '教室智能人脸考勤系统'
const store = useUserStore()
if (!store.token && to.path !== '/login') {
next('/login')
} else {
next()
}
})
export default router

@ -0,0 +1,22 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useAppStore = defineStore('app', () => {
const sidebarCollapsed = ref(false)
const loading = ref(false)
const toggleSidebar = () => {
sidebarCollapsed.value = !sidebarCollapsed.value
}
const setLoading = (val) => {
loading.value = val
}
return {
sidebarCollapsed,
loading,
toggleSidebar,
setLoading
}
})

@ -0,0 +1,83 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { logout as logoutApi } from '@/api/auth'
export const useUserStore = defineStore('user', () => {
const token = ref('')
const userInfo = ref({
userId: null,
username: '',
realName: '',
avatar: '',
role: '',
roleName: '',
schoolName: ''
})
const name = computed(() => userInfo.value.realName || userInfo.value.username || '未知用户')
const roleLabel = computed(() => {
return userInfo.value.roleName || '未知'
})
const permissions = computed(() => {
const role = userInfo.value.role
const permMap = {
admin: ['dashboard', 'attendance', 'classroom', 'behavior', 'history', 'bigscreen', 'settings'],
staff: ['dashboard', 'attendance', 'classroom', 'history'],
teacher: ['dashboard', 'attendance', 'behavior', 'history']
}
return permMap[role] || []
})
const hasPermission = (page) => {
return permissions.value.includes(page)
}
/** 登录成功后设置 token 和用户信息 */
const setLoginData = (data) => {
token.value = data.token
userInfo.value = {
userId: data.userId,
username: data.username,
realName: data.realName,
avatar: data.avatar,
role: data.role,
roleName: data.roleName,
schoolName: data.schoolName
}
}
/** 退出登录 */
const logout = async () => {
try {
await logoutApi()
} catch {
// 即使接口失败,也清除本地状态
}
token.value = ''
userInfo.value = {
userId: null,
username: '',
realName: '',
avatar: '',
role: '',
roleName: '',
schoolName: ''
}
}
return {
token,
userInfo,
name,
roleName: roleLabel,
permissions,
hasPermission,
setLoginData,
logout
}
}, {
persist: true
})

@ -0,0 +1,184 @@
// === element-plus ===
// Element Plus
:root {
--el-color-primary: #52c41a;
--el-color-primary-light-3: #84d155;
--el-color-primary-light-5: #a7e280;
--el-color-primary-light-7: #c9efb0;
--el-color-primary-light-8: #dcf5cc;
--el-color-primary-light-9: #eefae6;
--el-color-primary-dark-2: #49b018;
--el-color-success: #52c41a;
--el-color-danger: #f5222d;
--el-color-warning: #faad14;
--el-color-info: #909399;
--el-border-radius-base: 6px;
--el-border-radius-small: 4px;
--el-border-radius-round: 20px;
--el-font-family: 'Source Han Sans CN', '思源黑体', 'Noto Sans SC',
'PingFang SC', 'Microsoft YaHei', sans-serif;
}
//
.el-button {
&--primary {
--el-button-bg-color: #52c41a;
--el-button-border-color: #52c41a;
--el-button-hover-bg-color: #49b018;
--el-button-hover-border-color: #49b018;
--el-button-active-bg-color: #3d9412;
--el-button-active-border-color: #3d9412;
border-radius: 8px;
font-weight: 500;
}
}
//
.el-input {
--el-input-border-radius: 6px;
.el-input__wrapper {
border-radius: 6px;
box-shadow: 0 0 0 1px #d9d9d9 inset;
transition: box-shadow 0.2s ease;
&:hover {
box-shadow: 0 0 0 1px #b3b3b3 inset;
}
}
&.is-focus .el-input__wrapper {
box-shadow: 0 0 0 1px #1890ff inset !important;
}
}
//
.el-menu {
border-right: none !important;
.el-menu-item {
border-radius: 8px;
margin: 2px 8px;
height: 44px;
line-height: 44px;
font-size: 14px;
transition: background-color 0.2s ease, color 0.2s ease;
will-change: background-color;
&:hover {
background-color: #e8f9e0 !important;
color: #52c41a !important;
}
&.is-active {
background: linear-gradient(135deg, #52c41a, #49b018) !important;
color: #ffffff !important;
font-weight: 500;
box-shadow: 0 2px 8px rgba(82, 196, 26, 0.3);
}
}
.el-sub-menu {
margin: 2px 8px;
.el-sub-menu__title {
border-radius: 8px;
height: 44px;
line-height: 44px;
transition: background-color 0.2s ease, color 0.2s ease;
&:hover {
background-color: #e8f9e0 !important;
color: #52c41a !important;
}
}
}
}
//
.el-card {
border-radius: 8px;
border: 1px solid #f0f0f0;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
transition: box-shadow 0.3s ease;
&:hover {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
}
}
//
.el-table {
--el-table-border-color: #f0f0f0;
--el-table-header-bg-color: #fafafa;
border-radius: 8px;
font-size: 14px;
th.el-table__cell {
background-color: #fafafa;
color: #262626;
font-weight: 500;
height: 48px;
}
.el-table__row {
transition: background-color 0.15s ease;
&:hover > td.el-table__cell {
background-color: #e8f9e0;
}
}
}
//
.el-pagination {
--el-pagination-hover-color: #52c41a;
margin-top: 16px;
justify-content: flex-end;
}
//
.el-dialog {
border-radius: 12px;
overflow: hidden;
.el-dialog__header {
margin: 0;
padding: 16px 20px;
border-bottom: 1px solid #f0f0f0;
}
.el-dialog__body {
padding: 20px;
}
.el-dialog__footer {
padding: 12px 20px;
border-top: 1px solid #f0f0f0;
}
}
// /
.el-tag {
border-radius: 4px;
font-size: 12px;
}
//
.el-dropdown-menu {
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
border: 1px solid #f0f0f0;
}
//
.el-select {
--el-select-border-color-hover: #b3b3b3;
--el-select-input-focus-border-color: #1890ff;
}
//
.el-date-editor {
--el-date-editor-active-border-color: #1890ff;
}

@ -0,0 +1,195 @@
// === ===
@use './variables.scss' as *;
*,
*::before,
*::after {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
height: 100%;
font-family: $font-family;
font-size: $font-body;
color: $text-primary;
background-color: $bg-page;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
#app {
height: 100%;
}
a {
color: $color-primary;
text-decoration: none;
&:hover {
color: $color-primary-hover;
}
}
//
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #d9d9d9;
border-radius: 3px;
&:hover {
background: #bfbfbf;
}
}
//
.flex-center {
display: flex;
align-items: center;
justify-content: center;
}
.flex-between {
display: flex;
align-items: center;
justify-content: space-between;
}
.flex-start {
display: flex;
align-items: center;
justify-content: flex-start;
}
.text-primary {
color: $text-primary;
}
.text-secondary {
color: $text-secondary;
}
.text-green {
color: $color-primary;
}
.text-red {
color: $color-danger;
}
.text-small {
font-size: $font-caption;
}
//
.page-container {
padding: 20px;
min-height: calc(100vh - $navbar-height);
}
.page-header {
margin-bottom: 20px;
.page-title {
font-size: $font-title;
font-weight: 700;
color: $text-primary;
margin-bottom: 4px;
}
.page-subtitle {
font-size: $font-caption;
color: $text-secondary;
}
}
//
.filter-bar {
background: $bg-card;
border-radius: $radius-md;
padding: 16px 20px;
margin-bottom: 16px;
box-shadow: $shadow-card;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 12px;
}
//
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 16px;
margin-bottom: 20px;
}
//
@keyframes skeleton-loading {
0% {
background-position: -200px 0;
}
100% {
background-position: calc(200px + 100%) 0;
}
}
.skeleton {
background: linear-gradient(90deg, #f0f0f0 25%, #e8e8e8 37%, #f0f0f0 63%);
background-size: 400% 100%;
animation: skeleton-loading 1.4s ease infinite;
border-radius: $radius-md;
}
//
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.fade-in-up {
animation: fadeInUp 0.4s ease forwards;
}
//
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
.pulse {
animation: pulse 2s ease-in-out infinite;
}
//
@media (max-width: $breakpoint-sm) {
.stats-grid {
grid-template-columns: 1fr;
}
.filter-bar {
flex-direction: column;
align-items: stretch;
}
}

@ -0,0 +1,71 @@
// === SCSS ===
//
$color-primary: #52c41a;
$color-primary-hover: #49b018;
$color-primary-light: #e8f9e0;
$color-white: #ffffff;
$color-danger: #f5222d;
$color-danger-light: #fff1f0;
$color-warning: #faad14;
$color-info: #1890ff;
//
$bg-page: #f5f7fa;
$bg-card: #ffffff;
$bg-hover: #fafafa;
$bg-active: #f0fdf0;
//
$text-primary: #262626;
$text-secondary: #525252;
$text-placeholder: #bfbfbf;
$text-disabled: #d9d9d9;
$text-white: #ffffff;
//
$border-color: #d9d9d9;
$border-focus: #1890ff;
$border-light: #f0f0f0;
//
$radius-sm: 6px;
$radius-md: 8px;
$radius-lg: 12px;
$radius-round: 50%;
//
$shadow-card: 0 2px 8px rgba(0, 0, 0, 0.05);
$shadow-hover: 0 4px 16px rgba(0, 0, 0, 0.08);
$shadow-dropdown: 0 4px 20px rgba(0, 0, 0, 0.1);
//
$btn-height-primary: 40px;
$btn-height-secondary: 36px;
$btn-height-small: 30px;
//
$font-title: 18px;
$font-subtitle: 16px;
$font-body: 14px;
$font-caption: 12px;
// 退
$font-family: 'Source Han Sans CN', '思源黑体', 'Noto Sans SC', 'PingFang SC',
'Microsoft YaHei', 'Helvetica Neue', Arial, sans-serif;
$font-family-mono: 'SF Mono', 'Monaco', 'Consolas', 'Courier New', monospace;
//
$sidebar-width: 240px;
$sidebar-collapsed-width: 64px;
$navbar-height: 90px;
//
$transition-fast: 0.15s ease;
$transition-normal: 0.3s ease;
$transition-slow: 0.5s ease;
//
$breakpoint-sm: 768px;
$breakpoint-md: 1024px;
$breakpoint-lg: 1440px;

@ -0,0 +1,2 @@
const fileHttp = 'https://shipllm.ngsk.tech:7001/storage'
export default fileHttp

@ -0,0 +1,68 @@
import axios from 'axios'
import { ElMessage } from 'element-plus'
import { useUserStore } from '@/stores/user'
let isTokenExpired = false
const request = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL,
timeout: 15000,
headers: { 'Content-Type': 'application/json;charset=UTF-8' }
})
// 请求拦截器
request.interceptors.request.use(
(config) => {
const store = useUserStore()
if (store.token) {
config.headers.Authorization = `Bearer ${store.token}`
}
// FormData让浏览器自动设置带 boundary 的 Content-Type
if (config.data instanceof FormData) {
delete config.headers['Content-Type']
}
return config
},
(error) => Promise.reject(error)
)
// 响应拦截器
request.interceptors.response.use(
(response) => {
const { code, message } = response.data
if (code === 200) {
return response.data
}
ElMessage.error(message || '请求失败')
return Promise.reject(new Error(message))
},
(error) => {
if (error.response) {
const { status } = error.response
switch (status) {
case 401:
if (!isTokenExpired) {
isTokenExpired = true
ElMessage.error('登录已过期,请重新登录')
useUserStore().$patch({ token: '' })
window.location.hash = '#/login'
setTimeout(() => { isTokenExpired = false }, 2000)
}
break
case 403:
ElMessage.error('没有访问权限')
break
case 500:
ElMessage.error('服务器异常,请稍后重试')
break
default:
ElMessage.error(`请求错误 ${status}`)
}
} else {
ElMessage.error('网络异常,请检查网络连接')
}
return Promise.reject(error)
}
)
export default request

@ -0,0 +1,668 @@
<template>
<div class="page-container fade-in-up">
<div class="page-header">
<h2 class="page-title">课堂行为分析</h2>
<p class="page-subtitle">基于AI人脸识别分析课堂学生行为数据</p>
</div>
<!-- 课程选择区 -->
<div class="filter-bar">
<el-select v-model="filters.courseId" placeholder="全部课程" clearable filterable size="default" style="width: 200px">
<el-option v-for="c in courseList" :key="c.id" :label="c.courseName" :value="c.id" />
</el-select>
<el-select v-model="filters.teacherId" placeholder="全部教师" clearable filterable size="default" style="width: 180px">
<el-option v-for="t in teacherList" :key="t.id" :label="t.name" :value="t.id" />
</el-select>
<el-date-picker v-model="filters.date" type="date" placeholder="选择日期" size="default" value-format="YYYY-MM-DD" />
<el-button type="primary" :icon="Search" :loading="loading" @click="fetchData"></el-button>
<el-button :icon="RefreshRight" @click="handleReset"></el-button>
</div>
<!-- 行为统计图表 -->
<div class="charts-row-2">
<!-- 行为分布饼图 -->
<div class="chart-card">
<div class="chart-header">
<h3>行为分布占比</h3>
<!-- <el-tag size="small" type="success">今日</el-tag> -->
</div>
<div ref="pieChartRef" class="chart-body"></div>
<div class="behavior-legend">
<span v-for="b in behaviors" :key="b.name" class="legend-dot" :style="{ background: b.color }">
{{ b.name }} {{ behaviorPercent(b) }}%
</span>
</div>
</div>
<!-- 行为时长柱状图 -->
<div class="chart-card">
<div class="chart-header">
<h3>各行为时段分布</h3>
</div>
<div ref="barChartRef" class="chart-body"></div>
</div>
</div>
<!-- 行为标记图片列表 -->
<div class="image-section">
<div class="section-header">
<h3>行为标记图片</h3>
<el-radio-group v-model="imageFilter" size="small" @change="onFilterChange">
<el-radio-button value="">全部</el-radio-button>
<el-radio-button
v-for="bt in behaviorTypes"
:key="bt.id"
:value="bt.id"
>{{ bt.typeName }}</el-radio-button>
</el-radio-group>
</div>
<div v-if="imageRecords.length" class="image-grid">
<div
v-for="(img, index) in imageRecords"
:key="img.id || index"
class="image-card"
@click="previewImage(img)"
>
<div class="image-placeholder" :style="{ borderColor: getTypeColor(img.behaviorTypeId) }">
<img v-if="img.snapshotUrl" :src="img.snapshotUrl" alt="" class="snapshot-img" />
<el-icon v-else :size="32" :color="getTypeColor(img.behaviorTypeId)">
<PictureFilled />
</el-icon>
</div>
<div class="image-info">
<div class="info-row">
<span class="info-label">课程</span>
<span class="info-value">{{ img.courseName }}</span>
</div>
<div class="info-row">
<span class="info-label">教师</span>
<span class="info-value">{{ img.teacherName }}</span>
</div>
<div class="info-row">
<span class="info-label">学生</span>
<span class="info-value">{{ img.studentName }}</span>
</div>
<div class="info-bottom">
<span class="image-time">{{ img.behaviorTime }}</span>
<el-tag :color="getTypeColor(img.behaviorTypeId)" size="small" effect="dark">
{{ img.behaviorTypeName }}
</el-tag>
</div>
</div>
<div class="image-check" @click.stop>
<el-checkbox v-model="img.checked" />
</div>
</div>
</div>
<el-empty v-else description="暂无数据" :image-size="80" />
<div v-if="imageRecords.length" class="image-actions">
<el-pagination
size="small"
background
layout="prev, pager, next"
:total="pagination.total"
:current-page="pagination.current"
:page-size="pagination.size"
@current-change="onPageChange"
/>
<el-button v-if="hasChecked" type="primary" size="small" :icon="Download" @click="batchDownload">
批量下载 ({{ checkedCount }})
</el-button>
</div>
</div>
<!-- 图片预览 -->
<el-dialog v-model="previewVisible" title="图片预览" width="600px" align-center>
<div class="preview-container">
<div class="preview-placeholder">
<img v-if="currentPreview?.snapshotUrl" :src="currentPreview.snapshotUrl" alt="" class="preview-img" />
<template v-else>
<el-icon :size="64" color="#d9d9d9"><PictureFilled /></el-icon>
<p>暂无图片</p>
</template>
<span class="preview-meta">{{ currentPreview?.behaviorTime }} | {{ currentPreview?.behaviorTypeName }}</span>
</div>
</div>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
import { ElMessage } from 'element-plus'
import * as echarts from 'echarts'
import { Search, RefreshRight, PictureFilled, Download } from '@element-plus/icons-vue'
import { getBehaviorTypesWithStats, getBehaviorTimePeriod, getBehaviorRecords, getBehaviorTypes } from '@/api/behavior'
import { getCourseList, getTeacherList } from '@/api/info'
const filters = reactive({
courseId: '',
teacherId: '',
date: null
})
const courseList = ref([])
const teacherList = ref([])
const imageFilter = ref('')
//
const behaviorTypes = ref([])
//
const behaviors = ref([])
const loading = ref(false)
const pieChartRef = ref(null)
const barChartRef = ref(null)
let pieChart = null
let barChart = null
//
const imageRecords = ref([])
const pagination = reactive({
current: 1,
size: 10,
total: 0
})
const checkedCount = computed(() => imageRecords.value.filter(i => i.checked).length)
const hasChecked = computed(() => checkedCount.value > 0)
const previewVisible = ref(false)
const currentPreview = ref(null)
/** 根据行为类型 ID 获取对应颜色 */
const getTypeColor = (behaviorTypeId) => {
const bt = behaviorTypes.value.find(t => t.id === behaviorTypeId)
return bt ? bt.color : '#bfbfbf'
}
/** 计算单个行为占比 */
const behaviorPercent = (b) => {
if (!behaviors.value.length) return 0
const total = behaviors.value.reduce((sum, item) => sum + (item.count || 0), 0)
return total > 0 ? ((b.count / total) * 100).toFixed(1) : 0
}
const previewImage = (img) => {
currentPreview.value = img
previewVisible.value = true
}
const batchDownload = () => {
ElMessage.success(`正在下载 ${checkedCount.value} 张图片...`)
}
/** 获取行为类型列表 */
const fetchBehaviorTypes = async () => {
try {
const res = await getBehaviorTypes()
if (res && res.data) {
behaviorTypes.value = res.data
imageFilter.value = ''
}
} catch { /* 错误已在拦截器统一处理 */ }
}
/** 获取全部课程列表(下拉用) */
const fetchAllCourses = async () => {
try {
const res = await getCourseList()
if (res && res.data) {
courseList.value = res.data
}
} catch { /* 错误已在拦截器统一处理 */ }
}
/** 获取全部教师列表(下拉用) */
const fetchAllTeachers = async () => {
try {
const res = await getTeacherList()
if (res && res.data) {
teacherList.value = res.data
}
} catch { /* 错误已在拦截器统一处理 */ }
}
/** 获取行为标记图片记录 */
const fetchRecords = async (page = 1) => {
try {
const params = {
current: page,
size: pagination.size,
courseId: filters.courseId || undefined,
teacherId: filters.teacherId || undefined,
attDate: filters.date || undefined,
behaviorTypeId: imageFilter.value || undefined
}
const res = await getBehaviorRecords(params)
if (res && res.data) {
imageRecords.value = (res.data.records || []).map(r => ({
...r,
checked: false
}))
pagination.current = res.data.current
pagination.total = res.data.total
pagination.size = res.data.size
}
} catch { /* 错误已在拦截器统一处理 */ }
}
/** 重置筛选条件 */
const handleReset = () => {
filters.courseId = ''
filters.teacherId = ''
filters.date = null
imageFilter.value = ''
pagination.current = 1
fetchData()
}
/** 查询按钮 */
const fetchData = async () => {
loading.value = true
pagination.current = 1
try {
const params = {
courseId: filters.courseId || undefined,
teacherId: filters.teacherId || undefined,
attDate: filters.date || undefined
}
const [res1, res2] = await Promise.all([
getBehaviorTypesWithStats(params),
getBehaviorTimePeriod(params)
])
//
if (res1 && res1.data) {
behaviors.value = res1.data.map(item => ({
id: item.id,
typeCode: item.typeCode,
name: item.typeName,
color: item.color,
count: item.count,
category: item.category,
description: item.description
}))
updatePieChart()
}
//
if (res2 && res2.data) {
updateBarChart(res2.data)
}
//
await fetchRecords(1)
} catch {
//
} finally {
loading.value = false
}
}
/** 筛选条件变化时重新查询 */
const onFilterChange = () => {
pagination.current = 1
fetchRecords(1)
}
/** 分页切换 */
const onPageChange = (page) => {
fetchRecords(page)
}
/** 更新饼图数据 */
const updatePieChart = () => {
if (!pieChart) return
const total = behaviors.value.reduce((sum, b) => sum + (b.count || 0), 0)
pieChart.setOption({
series: [{
data: behaviors.value.map(b => ({
value: b.count,
name: b.name,
itemStyle: { color: b.color }
}))
}],
tooltip: {
trigger: 'item',
formatter: (params) => {
const pct = total > 0 ? ((params.value / total) * 100).toFixed(1) : 0
return `${params.name}: ${params.value} (${pct}%)`
}
}
})
}
/** 更新柱状图数据 */
const updateBarChart = (data) => {
if (!barChart || !data.length) return
// x
const xData = data.map(d => d.timeSlot)
//
const nameSet = new Set()
const nameList = []
const colorMap = {}
data.forEach(slot => {
(slot.data || []).forEach(item => {
if (!nameSet.has(item.name)) {
nameSet.add(item.name)
nameList.push(item.name)
colorMap[item.name] = item.color
}
})
})
// series
const series = nameList.map((name, idx) => ({
name,
type: 'bar',
stack: 'total',
data: data.map(slot => {
const found = (slot.data || []).find(item => item.name === name)
return found ? found.value : 0
}),
color: colorMap[name],
itemStyle: idx === 0 ? { borderRadius: [4, 4, 0, 0] } : {}
}))
barChart.setOption({
grid: { bottom: 70 },
xAxis: { data: xData },
legend: {
data: nameList,
bottom: 0,
type: 'scroll'
},
series
})
}
const initCharts = () => {
//
if (pieChartRef.value) {
pieChart = echarts.init(pieChartRef.value)
pieChart.setOption({
tooltip: { trigger: 'item' },
series: [{
type: 'pie',
radius: ['45%', '80%'],
center: ['50%', '50%'],
avoidLabelOverlap: false,
itemStyle: { borderRadius: 4, borderColor: '#fff', borderWidth: 2 },
label: { show: false },
emphasis: { label: { show: true, fontSize: 16, fontWeight: 'bold' } },
data: []
}]
})
}
//
if (barChartRef.value) {
barChart = echarts.init(barChartRef.value)
barChart.setOption({
tooltip: { trigger: 'axis' },
legend: { data: ['专注', '互动', '其他'], bottom: 0 },
grid: { top: 10, right: 20, bottom: 40, left: 40 },
xAxis: { type: 'category', data: ['08:00', '08:20', '08:40', '09:00', '09:20', '09:40'] },
yAxis: { type: 'value', name: '时长(分钟)' },
series: [
{ name: '专注', type: 'bar', stack: 'total', data: [15, 12, 14, 16, 13, 11], color: '#52c41a' },
{ name: '互动', type: 'bar', stack: 'total', data: [3, 5, 4, 2, 5, 4], color: '#1890ff' },
{ name: '其他', type: 'bar', stack: 'total', data: [2, 3, 2, 2, 2, 5], color: '#d9d9d9' }
]
})
}
}
onMounted(() => {
initCharts()
fetchBehaviorTypes()
fetchAllCourses()
fetchAllTeachers()
fetchData()
window.addEventListener('resize', () => {
pieChart?.resize()
barChart?.resize()
})
})
onUnmounted(() => {
pieChart?.dispose()
barChart?.dispose()
})
</script>
<style lang="scss" scoped>
.charts-row-2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-bottom: 20px;
}
.chart-card {
background: #ffffff;
border-radius: 8px;
padding: 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
.chart-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
h3 {
font-size: 16px;
font-weight: 600;
color: #262626;
}
}
.chart-body {
height: 280px;
}
}
.behavior-legend {
display: flex;
justify-content: center;
gap: 16px;
margin-top: 8px;
flex-wrap: wrap;
}
.legend-dot {
font-size: 12px;
color: #ffffff;
display: flex;
align-items: center;
gap: 4px;
padding: 2px 5px;
border-radius: 4px;
// &::before {
// content: '';
// width: 8px;
// height: 8px;
// border-radius: 50%;
// background: inherit;
// }
}
.image-section {
background: #ffffff;
border-radius: 8px;
padding: 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
h3 {
font-size: 16px;
font-weight: 600;
color: #262626;
}
}
}
.image-grid {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 12px;
}
.image-card {
position: relative;
border-radius: 8px;
overflow: hidden;
cursor: pointer;
transition: all 0.3s ease;
border: 2px solid transparent;
&:hover {
border-color: #52c41a;
box-shadow: 0 4px 12px rgba(82, 196, 26, 0.15);
transform: translateY(-2px);
}
}
.image-placeholder {
height: 120px;
background: #f5f7fa;
display: flex;
align-items: center;
justify-content: center;
border-bottom: 3px solid #d9d9d9;
overflow: hidden;
.snapshot-img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.image-info {
padding: 8px 10px;
}
.info-row {
display: flex;
align-items: center;
gap: 2px;
margin-bottom: 2px;
font-size: 12px;
line-height: 1.6;
}
.info-label {
color: #8c8c8c;
flex-shrink: 0;
}
.info-value {
color: #262626;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.info-bottom {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 4px;
padding-top: 4px;
border-top: 1px solid #f0f0f0;
}
.image-time {
font-size: 11px;
color: #8c8c8c;
font-family: monospace;
}
.image-check {
position: absolute;
top: 6px;
right: 6px;
}
.image-actions {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 16px;
}
.preview-container {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.preview-placeholder {
width: 100%;
height: 360px;
background: #f5f7fa;
border-radius: 8px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
color: #bfbfbf;
font-size: 14px;
overflow: hidden;
.preview-img {
width: 100%;
height: 100%;
object-fit: contain;
}
}
.preview-meta {
font-size: 12px;
color: #bfbfbf;
}
@media (max-width: 1280px) {
.image-grid {
grid-template-columns: repeat(5, 1fr);
}
}
@media (max-width: 1024px) {
.charts-row-2 {
grid-template-columns: 1fr;
}
.image-grid {
grid-template-columns: repeat(4, 1fr);
}
}
@media (max-width: 768px) {
.image-grid {
grid-template-columns: repeat(3, 1fr);
}
}
@media (max-width: 480px) {
.image-grid {
grid-template-columns: repeat(2, 1fr);
}
}
</style>

@ -0,0 +1,726 @@
<template>
<div ref="bigscreenRef" class="bigscreen" @dblclick="toggleFullscreen">
<!-- 顶部标题区 -->
<header class="bs-header">
<div class="bs-header-left">
<div class="header-decoration"></div>
</div>
<div class="bs-header-center">
<h1 class="bs-title">教室智能人脸考勤数据大屏</h1>
<p class="bs-subtitle">Classroom Smart Attendance Data Dashboard</p>
</div>
<div class="bs-header-right">
<div class="header-time">
<el-icon><Clock /></el-icon>
<span>{{ currentTime }}</span>
</div>
<el-button :icon="Refresh" circle size="small" class="refresh-btn" @click="refreshData" />
<el-tooltip :content="isFullscreen ? '退出全屏' : '全屏展示'" placement="bottom">
<el-button :icon="FullScreen" circle size="small" class="fullscreen-btn" @click="toggleFullscreen" />
</el-tooltip>
</div>
</header>
<!-- 核心数据卡片 -->
<div class="bs-stats">
<div class="bs-stat-card">
<div class="stat-icon" style="background: linear-gradient(135deg, #52c41a, #73d13d)">
<el-icon :size="28"><UserFilled /></el-icon>
</div>
<div class="stat-body">
<div class="stat-value">{{ stats.attendanceRate }}<span class="stat-unit">%</span></div>
<div class="stat-label">课程平均出勤率</div>
<div class="stat-trend" :class="stats.trend >= 0 ? 'up' : 'down'">
<el-icon><CaretTop v-if="stats.trend >= 0" /><CaretBottom v-else /></el-icon>
{{ Math.abs(stats.trend) }}% 较昨日
</div>
</div>
</div>
<div class="bs-stat-card">
<div class="stat-icon" style="background: linear-gradient(135deg, #722ed1, #9254de)">
<el-icon :size="28"><TrendCharts /></el-icon>
</div>
<div class="stat-body">
<div class="stat-value">{{ stats.focusRate }}<span class="stat-unit">%</span></div>
<div class="stat-label">课堂专注度占比</div>
<div class="stat-trend" :class="stats.focusTrend >= 0 ? 'up' : 'down'">
<el-icon><CaretTop v-if="stats.focusTrend >= 0" /><CaretBottom v-else /></el-icon>
{{ Math.abs(stats.focusTrend) }}% 较昨日
</div>
</div>
</div>
</div>
<!-- 多维度展示区 -->
<div class="bs-charts">
<!-- 左侧出勤趋势 -->
<div class="bs-panel">
<div class="panel-header">
<h3>出勤趋势</h3>
</div>
<div ref="trendChartRef" class="panel-body"></div>
</div>
<!-- 右侧行为分布饼图 -->
<div class="bs-panel">
<div class="panel-header">
<h3>课堂行为分布</h3>
</div>
<div ref="behaviorChartRef" class="panel-body"></div>
</div>
</div>
<!-- 底部教室概览 -->
<div class="bs-monitor">
<div class="monitor-header">
<h3>教室数据概览</h3>
<div class="monitor-selects">
<el-select v-model="selectedBuildingId" placeholder="选择教学楼" size="small" class="monitor-select" popper-class="monitor-select-popper" :teleported="false">
<el-option v-for="b in buildingList" :key="b.id" :label="b.buildingName" :value="b.id" />
</el-select>
</div>
</div>
<div class="room-grid">
<div v-if="filteredRooms.length === 0" class="monitor-empty">{{ buildingList.length === 0 ? '' : '' }}</div>
<div
v-for="room in filteredRooms"
:key="room.id"
class="room-card"
@click="goToRoomDetail(room)"
>
<div class="room-card-header">
<el-icon :size="20" color="#52c41a"><School /></el-icon>
<span class="room-name">{{ room.roomName }}</span>
</div>
<div class="room-card-body">
<div class="room-info-item">
<span class="info-label">教室编号</span>
<span class="info-value">{{ room.roomNo }}</span>
</div>
<div class="room-info-item">
<span class="info-label">容纳人数</span>
<span class="info-value">{{ room.capacity }} </span>
</div>
<div class="room-info-item">
<span class="info-label">摄像头</span>
<span class="info-value">{{ room.deviceCount ?? 0 }} </span>
</div>
</div>
<div class="room-card-footer">
<el-tag :type="room.status === 1 ? 'success' : 'info'" size="small" effect="dark">
{{ room.status === 1 ? '正常' : '异常'}}
</el-tag>
<el-icon :size="14" color="rgba(255,255,255,0.3)"><ArrowRight /></el-icon>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import * as echarts from 'echarts'
import { Refresh, FullScreen, School, ArrowRight } from '@element-plus/icons-vue'
import { getBigScreenStats, getBigScreenTrend, getBigScreenBehavior } from '@/api/bigscreen'
import { getBuildingList, getRoomsList } from '@/api/info'
const currentTime = ref('')
const timer = ref(null)
const router = useRouter()
//
const buildingList = ref([])
const roomList = ref([])
const selectedBuildingId = ref(null)
const filteredRooms = computed(() => {
if (!selectedBuildingId.value) return []
const id = Number(selectedBuildingId.value)
return roomList.value.filter(r => Number(r.buildingId) === id)
})
const goToRoomDetail = (room) => {
router.push({ name: 'BigScreenRoomDetail', params: { id: room.id } })
}
const trendChartRef = ref(null)
const behaviorChartRef = ref(null)
const trendChart = ref(null)
const stats = reactive({
attendanceRate: 0,
trend: 0,
focusRate: 0,
focusTrend: 0
})
const fetchStats = async () => {
try {
const res = await getBigScreenStats()
if (res?.data) {
stats.attendanceRate = res.data.attendanceRate ?? stats.attendanceRate
stats.trend = res.data.trend ?? stats.trend
stats.focusRate = res.data.focusRate ?? stats.focusRate
stats.focusTrend = res.data.focusTrend ?? stats.focusTrend
}
} catch {
ElMessage.warning('获取大屏数据失败')
}
}
const fetchTrend = async () => {
try {
const res = await getBigScreenTrend()
if (!res?.data || !trendChartRef.value) return
const { dates, rates } = res.data
const yMin = Math.max(0, Math.floor(Math.min(...rates) - 2))
const yMax = Math.min(100, Math.ceil(Math.max(...rates) + 2))
// tooltip
const old = echarts.getInstanceByDom(trendChartRef.value)
old?.dispose()
const chart = echarts.init(trendChartRef.value)
trendChart.value = chart
chart.setOption({
tooltip: {
trigger: 'axis',
backgroundColor: 'rgba(6, 12, 28, 0.92)',
borderColor: 'rgba(82, 196, 26, 0.35)',
textStyle: { color: '#fff', fontSize: 13 },
formatter: (params) => {
const p = Array.isArray(params) ? params[0] : params
return `<div style="line-height:1.8">
<span style="color:rgba(255,255,255,0.5)">日期</span><span style="color:#fff">${p.axisValue}</span><br/>
<span style="color:rgba(255,255,255,0.5)">出勤率</span><span style="color:#52c41a;font-weight:700;font-size:16px">${p.value}%</span>
</div>`
}
},
grid: { top: 20, right: 20, bottom: 20, left: 40 },
xAxis: { type: 'category', data: dates, axisLabel: { fontSize: 11 } },
yAxis: { type: 'value', min: yMin, max: yMax, axisLabel: { formatter: '{value}%' } },
series: [{
name: '出勤率',
data: rates,
type: 'line',
smooth: true,
symbol: 'circle',
symbolSize: 8,
lineStyle: { color: '#52c41a', width: 2.5 },
itemStyle: { color: '#52c41a' },
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: 'rgba(82,196,26,0.2)' },
{ offset: 1, color: 'rgba(82,196,26,0.02)' }
])
}
}]
})
} catch {
ElMessage.warning('获取出勤趋势失败')
}
}
const fetchBehavior = async () => {
try {
const res = await getBigScreenBehavior()
if (!res?.data || !behaviorChartRef.value) return
const old = echarts.getInstanceByDom(behaviorChartRef.value)
old?.dispose()
const chart = echarts.init(behaviorChartRef.value)
chart.setOption({
tooltip: {
trigger: 'item',
backgroundColor: 'rgba(6, 12, 28, 0.92)',
borderColor: 'rgba(114, 46, 209, 0.35)',
textStyle: { color: '#fff', fontSize: 13 },
formatter: '{b}: {c}%'
},
series: [{
type: 'pie',
radius: ['50%', '75%'],
center: ['50%', '55%'],
label: { fontSize: 11, color: 'rgba(255,255,255,0.6)' },
data: res.data.map(item => ({
value: item.value,
name: item.name,
itemStyle: { color: item.color }
}))
}]
})
} catch {
ElMessage.warning('获取行为分布失败')
}
}
const refreshData = () => {
Promise.all([fetchStats(), fetchTrend(), fetchBehavior()])
ElMessage.success('数据已刷新')
}
const bigscreenRef = ref(null)
const isFullscreen = ref(false)
const syncFullscreenState = () => {
isFullscreen.value = document.fullscreenElement === bigscreenRef.value
}
const toggleFullscreen = () => {
if (document.fullscreenElement) {
document.exitFullscreen()
} else {
bigscreenRef.value?.requestFullscreen()
}
}
onMounted(() => {
document.addEventListener('fullscreenchange', syncFullscreenState)
})
onUnmounted(() => {
document.removeEventListener('fullscreenchange', syncFullscreenState)
})
const fetchMonitorData = async () => {
try {
const [bRes, rRes] = await Promise.all([
getBuildingList(),
getRoomsList()
])
if (bRes?.data) buildingList.value = bRes.data
if (rRes?.data) roomList.value = rRes.data
//
if (buildingList.value.length > 0 && !selectedBuildingId.value) {
selectedBuildingId.value = buildingList.value[0].id
}
} catch {
ElMessage.warning('获取教室数据失败')
}
}
const updateTime = () => {
const now = new Date()
currentTime.value = now.toLocaleString('zh-CN', {
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit'
})
}
const initCharts = () => {
// fetchTrend API
// fetchBehavior API
}
onMounted(() => {
updateTime()
timer.value = setInterval(updateTime, 1000)
initCharts()
Promise.all([fetchStats(), fetchTrend(), fetchBehavior(), fetchMonitorData()])
window.addEventListener('resize', () => {
[trendChartRef.value, behaviorChartRef.value].forEach(el => {
const instance = echarts.getInstanceByDom(el)
instance?.resize()
})
})
})
onUnmounted(() => {
clearInterval(timer.value)
})
</script>
<style lang="scss" scoped>
.bigscreen {
width: 100%;
height: calc(100vh - 96px);
background: linear-gradient(180deg, #0a1628 0%, #132042 50%, #1a2d4a 100%);
color: #ffffff;
padding: 0 24px 20px;
overflow: hidden;
display: flex;
flex-direction: column;
}
/* 全屏时占满整个屏幕,隐藏左侧菜单和顶部导航 */
.bigscreen:fullscreen {
height: 100vh;
width: 100vw;
padding: 16px 32px 24px;
box-sizing: border-box;
}
.bs-header {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
height: 64px;
padding: 0 8px;
}
.bs-header-left,
.bs-header-right {
width: 200px;
display: flex;
align-items: center;
gap: 12px;
}
.bs-header-center {
text-align: center;
}
.bs-title {
font-size: 24px;
font-weight: 700;
background: linear-gradient(90deg, #52c41a, #73d13d);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin-bottom: 2px;
}
.bs-subtitle {
font-size: 12px;
color: rgba(255, 255, 255, 0.3);
letter-spacing: 2px;
}
.header-time {
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
color: rgba(255, 255, 255, 0.5);
font-family: monospace;
}
.refresh-btn,
.fullscreen-btn {
--el-button-bg-color: rgba(82, 196, 26, 0.12) !important;
--el-button-border-color: rgba(82, 196, 26, 0.25) !important;
--el-button-hover-bg-color: rgba(82, 196, 26, 0.22) !important;
--el-button-hover-border-color: rgba(82, 196, 26, 0.45) !important;
--el-button-text-color: #52c41a !important;
box-shadow: 0 0 12px rgba(82, 196, 26, 0.15);
transition: all 0.3s ease;
&:hover {
box-shadow: 0 0 20px rgba(82, 196, 26, 0.3);
transform: translateY(-1px);
}
}
.bs-stats {
flex-shrink: 0;
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
margin-bottom: 16px;
}
.bs-stat-card {
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 8px;
padding: 20px;
display: flex;
gap: 16px;
align-items: center;
backdrop-filter: blur(10px);
transition: all 0.3s ease;
&:hover {
border-color: rgba(82, 196, 26, 0.3);
background: rgba(82, 196, 26, 0.04);
}
}
.stat-icon {
width: 56px;
height: 56px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
.stat-body {
flex: 1;
}
.stat-value {
font-size: 36px;
font-weight: 700;
color: #ffffff;
line-height: 1;
}
.stat-unit {
font-size: 18px;
font-weight: 400;
opacity: 0.5;
}
.stat-label {
font-size: 13px;
color: rgba(255, 255, 255, 0.4);
margin-top: 4px;
margin-bottom: 6px;
}
.stat-trend {
font-size: 12px;
display: flex;
align-items: center;
gap: 2px;
&.up { color: #52c41a; }
&.down { color: #f5222d; }
}
.bs-charts {
flex-shrink: 0;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-bottom: 16px;
}
.bs-panel {
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 8px;
overflow: hidden;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
h3 {
font-size: 15px;
font-weight: 600;
color: rgba(255, 255, 255, 0.8);
}
}
.panel-body {
height: 260px;
}
.bs-monitor {
flex: 1;
min-height: 0;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 8px;
overflow: hidden;
display: flex;
flex-direction: column;
}
.monitor-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
h3 {
font-size: 15px;
font-weight: 600;
color: rgba(255, 255, 255, 0.8);
}
}
.monitor-selects {
display: flex;
gap: 8px;
}
.monitor-select {
width: 160px;
:deep(.el-select__wrapper) {
background: rgba(255, 255, 255, 0.06);
border-color: rgba(255, 255, 255, 0.12);
box-shadow: none;
}
:deep(.el-select__wrapper:hover) {
border-color: rgba(82, 196, 26, 0.35);
}
:deep(.el-select__wrapper.is-focus) {
border-color: rgba(82, 196, 26, 0.5);
box-shadow: 0 0 0 1px rgba(82, 196, 26, 0.2);
}
:deep(.el-select__placeholder) {
color: rgba(255, 255, 255, 0.3);
}
:deep(.el-select__placeholder.is-transparent) {
color: rgba(255, 255, 255, 0.3);
}
:deep(.el-select__selected-item) {
color: rgba(255, 255, 255, 0.85);
}
:deep(.el-select__caret) {
color: rgba(255, 255, 255, 0.35);
}
}
.monitor-grid {
flex: 1;
min-height: 0;
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-auto-rows: min-content;
gap: 12px;
padding: 16px;
overflow-y: auto;
align-content: start;
}
.monitor-empty {
grid-column: 1 / -1;
text-align: center;
padding: 48px 0;
color: rgba(255, 255, 255, 0.3);
font-size: 14px;
}
/* 教室卡片区域 */
.room-grid {
flex: 1;
min-height: 0;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 14px;
padding: 16px;
overflow-y: auto;
align-content: start;
}
.room-card {
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 10px;
padding: 16px;
cursor: pointer;
transition: all 0.3s ease;
display: flex;
flex-direction: column;
gap: 12px;
&:hover {
border-color: rgba(82, 196, 26, 0.4);
background: rgba(82, 196, 26, 0.06);
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(82, 196, 26, 0.12);
}
}
.room-card-header {
display: flex;
align-items: center;
gap: 8px;
.room-name {
font-size: 15px;
font-weight: 600;
color: rgba(255, 255, 255, 0.9);
}
}
.room-card-body {
display: flex;
flex-direction: column;
gap: 6px;
}
.room-info-item {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 13px;
.info-label {
color: rgba(255, 255, 255, 0.4);
}
.info-value {
color: rgba(255, 255, 255, 0.75);
font-weight: 500;
}
}
.room-card-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding-top: 8px;
border-top: 1px solid rgba(255, 255, 255, 0.06);
}
/* 视频放大弹窗 */
@media (max-width: 1200px) {
.bs-charts { grid-template-columns: 1fr 1fr; }
.room-grid { grid-template-columns: repeat(2, 1fr); }
}
</style>
<style lang="scss">
/* 大屏监控下拉框浮层 — popper-class 挂在 .el-popper 根元素上 */
.el-popper.monitor-select-popper {
/* 浮层容器:暗色背景 + 深色边框,与选中框视觉一致 */
&.is-light,
&.is-dark {
background-color: rgba(10, 22, 40, 0.96) !important;
border-color: rgba(60, 80, 110, 0.4) !important;
/* 小三角:背景和边框与浮层完全一致 */
.el-popper__arrow::before {
background-color: rgba(10, 22, 40, 0.96);
border-color: rgba(60, 80, 110, 0.4);
}
}
/* 下拉列表区域 */
.el-select-dropdown {
background: transparent !important;
}
/* 列表项文字 + hover / 选中态 */
.el-select-dropdown__item {
color: rgba(255, 255, 255, 0.75) !important;
}
.el-select-dropdown__item:hover,
.el-select-dropdown__item.is-hovering {
color: #fff !important;
background: rgba(82, 196, 26, 0.15) !important;
}
.el-select-dropdown__item.is-selected {
color: #52c41a !important;
background: rgba(82, 196, 26, 0.12) !important;
font-weight: 600;
}
}
</style>

@ -0,0 +1,555 @@
<template>
<div class="room-detail">
<!-- 头部 -->
<div class="detail-header">
<el-button :icon="ArrowLeft" @click="$router.back()"></el-button>
<div class="header-info">
<div class="header-title-row">
<h2>{{ room.roomName }} 监控详情</h2>
<el-tag v-if="courseName" type="warning" effect="plain" class="header-tag">
<!-- <el-icon :size="14"><Reading /></el-icon> -->
课程
{{ courseName }}
</el-tag>
<el-tag v-if="teacherName" type="primary" effect="plain" class="header-tag">
<!-- <el-icon :size="14"><UserFilled /></el-icon> -->
授课老师
{{ teacherName }}
</el-tag>
</div>
<div class="header-meta">
<span>教室编号{{ room.roomNo || '-' }}</span>
<i class="meta-divider">|</i>
<span>容纳 {{ room.capacity || '-' }} </span>
<i class="meta-divider">|</i>
<span>摄像头 {{ cameras.length }} </span>
</div>
</div>
<div class="header-status">
<span class="status-dot online" v-if="onlineCount > 0"></span>
<span class="status-dot offline" v-else></span>
<span>{{ onlineCount > 0 ? `${onlineCount} 路在线` : '全部离线' }}</span>
</div>
</div>
<!-- 主体两栏布局 -->
<div class="detail-body">
<!-- 左栏摄像头画面 -->
<div class="camera-panel">
<div class="panel-title">
<el-icon :size="18"><VideoCamera /></el-icon>
<span>实时画面</span>
</div>
<div class="camera-grid" :class="{ 'single': cameras.length === 1 }">
<template v-for="camera in cameras" :key="camera.id">
<div class="camera-cell">
<WebRtcPlayer
v-if="camera.streamUrl"
:src="`${camera.streamUrl}/whep`"
@connection-status="(ok) => updateCameraStatus(camera.id, ok)"
/>
<div v-else class="cell-no-stream">
<el-icon :size="36" color="#d9d9d9"><VideoCameraFilled /></el-icon>
</div>
<div class="camera-label">
<span class="dot" :class="getCameraStatus(camera.id)"></span>
{{ camera.name || camera.deviceNo }}
</div>
</div>
</template>
</div>
<div v-if="cameras.length === 0" class="panel-empty">
<el-icon :size="48" color="#d9d9d9"><VideoCameraFilled /></el-icon>
<p>该教室暂未配置摄像头</p>
</div>
</div>
<!-- 右栏人脸识别记录 -->
<div class="recognition-panel">
<div class="panel-title">
<el-icon :size="18"><UserFilled /></el-icon>
<span>人脸识别记录</span>
</div>
<!-- 统计卡片 -->
<div class="recognition-stats">
<div class="stat-card">
<div class="stat-num">{{ stats.total }}</div>
<div class="stat-label">应到</div>
</div>
<div class="stat-card">
<div class="stat-num green">{{ stats.present }}</div>
<div class="stat-label">已到</div>
</div>
<div class="stat-card">
<div class="stat-num orange">{{ stats.missed }}</div>
<div class="stat-label">未到</div>
</div>
<div class="stat-card">
<div class="stat-num blue">{{ stats.rate }}%</div>
<div class="stat-label">出勤率</div>
</div>
</div>
<!-- 学生签到表标题 -->
<div class="sub-title">
<el-icon :size="14"><List /></el-icon>
<span>学生签到表</span>
<span class="sub-title-count" v-if="records.length">{{ records.length }} </span>
</div>
<!-- 识别列表 -->
<div class="recognition-list">
<div
v-for="record in records"
:key="record.id"
class="recognition-item"
>
<div class="rec-avatar">{{ record.studentName ? record.studentName[0] : '?' }}</div>
<div class="rec-info">
<div class="rec-name">{{ record.studentName || '未知' }}</div>
<div class="rec-meta">学号{{ record.studentNo }}</div>
<div class="rec-meta">签到时间{{ formatTime(record.checkInTime) }}</div>
</div>
<el-tag
:type="record.status === '正常' ? 'success' : 'danger'"
size="small"
effect="plain"
>
{{ record.status }}
</el-tag>
<span class="rec-confidence" v-if="record.confidence">{{ record.confidence }}%</span>
</div>
<div v-if="records.length === 0" class="list-empty"></div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
import { useRoute } from 'vue-router'
import { ArrowLeft, Reading, List } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { getCameras, getRoomsList } from '@/api/info'
import { getCurrentAttendance } from '@/api/attendance'
import WebRtcPlayer from '@/components/WebRtcPlayer.vue'
const route = useRoute()
const roomId = Number(route.params.id)
const room = ref({})
const cameras = ref([])
const records = ref([])
// &
const courseName = ref('')
const teacherName = ref('')
//
const cameraStatus = reactive({})
const getCameraStatus = (id) => cameraStatus[id] || 'connecting'
const updateCameraStatus = (id, ok) => { cameraStatus[id] = ok ? 'online' : 'offline' }
const onlineCount = computed(() => cameras.value.filter(c => getCameraStatus(c.id) === 'online').length)
//
const stats = reactive({ total: 0, present: 0, missed: 0, rate: 0 })
const formatTime = (t) => {
if (!t) return '-'
const d = new Date(t)
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}:${String(d.getSeconds()).padStart(2, '0')}`
}
const now = () => {
const d = new Date()
const pad = (n) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
}
const loadRoomInfo = async () => {
try {
const res = await getRoomsList()
const list = res?.data || []
room.value = list.find(r => r.id === roomId) || { roomName: `教室 #${roomId}` }
} catch { /* 错误已由拦截器统一处理 */ }
}
const loadCameras = async () => {
try {
const res = await getCameras({ classroomId: roomId, current: 1, size: 50 })
const data = res?.data || {}
cameras.value = (data.records || []).map(c => ({
id: c.id,
name: c.deviceName,
deviceNo: c.deviceNo,
streamUrl: c.streamUrl,
online: c.onlineStatus === 1
}))
} catch { /* 错误已由拦截器统一处理 */ }
}
const loadRecords = async () => {
try {
const res = await getCurrentAttendance(roomId, now())
const data = res?.data
if (!data) return
// &
courseName.value = data.courseName || ''
teacherName.value = data.teacherName || ''
//
stats.total = data.totalCount || 0
stats.present = data.actualCount || 0
stats.missed = stats.total - stats.present
stats.rate = data.totalCount > 0 ? Math.round((data.actualCount / data.totalCount) * 100) : 0
//
const list = data.detailList || []
records.value = list.map(r => ({
id: r.id,
studentName: r.studentName || '未知',
studentNo: r.studentNo || '-',
status: r.attStatusDesc || (r.attStatus === 1 ? '正常' : '异常'),
checkInTime: r.checkInTime,
confidence: r.faceSimilarity
}))
} catch { /* 错误已由拦截器统一处理 */ }
}
let refreshTimer = null
onMounted(async () => {
await loadRoomInfo()
await Promise.all([loadCameras(), loadRecords()])
// 10
refreshTimer = setInterval(() => {
loadCameras()
loadRecords()
}, 10000)
})
onUnmounted(() => {
clearInterval(refreshTimer)
})
</script>
<style lang="scss" scoped>
.room-detail {
padding: 24px;
height: 100%;
display: flex;
flex-direction: column;
background: #f5f7fa;
}
/* ===== Header ===== */
.detail-header {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 20px;
flex-shrink: 0;
}
.header-info {
flex: 1;
min-width: 0;
}
.header-title-row {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 4px;
h2 {
font-size: 18px;
font-weight: 600;
color: #262626;
margin: 0;
}
}
.header-tag {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 13px;
padding: 0 10px;
line-height: 24px;
}
.header-meta {
font-size: 13px;
color: #999;
display: flex;
align-items: center;
gap: 8px;
.meta-divider {
font-style: normal;
color: #e0e0e0;
}
}
.header-status {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: #666;
flex-shrink: 0;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
&.online {
background: #52c41a;
box-shadow: 0 0 6px rgba(82, 196, 26, 0.6);
}
&.offline {
background: #d9d9d9;
}
}
/* ===== Body ===== */
.detail-body {
flex: 1;
min-height: 0;
display: flex;
gap: 20px;
overflow: hidden;
}
.panel-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 15px;
font-weight: 600;
color: #262626;
margin-bottom: 14px;
flex-shrink: 0;
}
/* ===== Left: Camera ===== */
.camera-panel {
flex: 1;
min-width: 0;
background: #fff;
border-radius: 10px;
padding: 18px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
display: flex;
flex-direction: column;
overflow: hidden;
}
.camera-grid {
flex: 1;
min-height: 0;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
overflow-y: auto;
&.single {
grid-template-columns: 1fr;
}
}
.camera-cell {
border-radius: 8px;
overflow: hidden;
border: 1px solid #f0f0f0;
display: flex;
flex-direction: column;
}
.cell-no-stream {
flex: 1;
min-height: 180px;
background: #fafafa;
display: flex;
align-items: center;
justify-content: center;
}
.camera-label {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
font-size: 13px;
color: #525252;
background: #fafafa;
border-top: 1px solid #f0f0f0;
.dot {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
&.online {
background: #52c41a;
box-shadow: 0 0 4px rgba(82, 196, 26, 0.5);
}
&.offline {
background: #d9d9d9;
}
&.connecting {
background: #faad14;
animation: pulse 1.2s ease-in-out infinite;
}
}
}
@keyframes pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.4; transform: scale(0.7); }
}
/* ===== Right: Recognition ===== */
.recognition-panel {
width: 360px;
flex-shrink: 0;
background: #fff;
border-radius: 10px;
padding: 18px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
display: flex;
flex-direction: column;
overflow: hidden;
}
/* 统计卡片 */
.recognition-stats {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
margin-bottom: 16px;
flex-shrink: 0;
}
.stat-card {
background: #fafafa;
border-radius: 8px;
padding: 14px 12px;
text-align: center;
border: 1px solid #f0f0f0;
}
.stat-num {
font-size: 26px;
font-weight: 700;
color: #262626;
line-height: 1.2;
&.green { color: #52c41a; }
&.orange { color: #fa8c16; }
&.blue { color: #409eff; }
}
.stat-label {
font-size: 12px;
color: #999;
margin-top: 4px;
}
/* 学生签到表标题 */
.sub-title {
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
font-weight: 600;
color: #262626;
margin-bottom: 10px;
flex-shrink: 0;
}
.sub-title-count {
font-weight: 400;
font-size: 13px;
color: #999;
}
/* 识别列表 */
.recognition-list {
flex: 1;
min-height: 0;
overflow-y: auto;
}
.recognition-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 8px;
border-bottom: 1px solid #f5f5f5;
transition: background 0.2s;
&:hover {
background: #fafafa;
}
}
.rec-avatar {
width: 38px;
height: 38px;
border-radius: 50%;
background: linear-gradient(135deg, #667eea, #764ba2);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 15px;
font-weight: 600;
flex-shrink: 0;
}
.rec-info {
flex: 1;
min-width: 0;
}
.rec-name {
font-size: 14px;
font-weight: 500;
color: #262626;
}
.rec-meta {
font-size: 12px;
color: #999;
margin-top: 2px;
}
.rec-confidence {
font-size: 12px;
color: #52c41a;
font-weight: 600;
flex-shrink: 0;
}
.panel-empty,
.list-empty {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
color: #999;
font-size: 14px;
p { margin: 0; }
}
</style>

@ -0,0 +1,272 @@
<template>
<el-dialog
:model-value="props.modelValue"
@update:model-value="emit('update:modelValue', $event)"
:title="`${props.detailData.courseName} - 考勤详情`"
width="1030px"
destroy-on-close
>
<div class="detail-header">
<span>{{ props.detailData.time }}</span>
<span>{{ props.detailData.classroom }}</span>
<span>应到 {{ props.detailData.total }} / 实到 {{ props.detailData.actual }}</span>
</div>
<el-tabs v-model="detailTab">
<el-tab-pane label="考勤详情" name="detail">
<!-- 搜索区 -->
<div class="search-bar">
<el-input v-model="searchForm.studentName" placeholder="学生姓名" clearable size="default" style="width: 180px" @clear="handleSearch" />
<el-select v-model="searchForm.attStatus" placeholder="考勤状态" clearable size="default" style="width: 140px" @change="handleSearch">
<el-option label="未签到" :value="0" />
<el-option label="正常" :value="1" />
<el-option label="迟到" :value="2" />
<el-option label="缺勤" :value="3" />
<el-option label="早退" :value="4" />
<el-option label="请假" :value="5" />
</el-select>
<el-button type="primary" size="default" @click="handleSearch"></el-button>
<el-button size="default" @click="handleReset"></el-button>
</div>
<el-table :data="detailList" stripe v-loading="loading" max-height="380" @selection-change="handleSelectionChange">
<el-table-column type="selection" width="45" />
<el-table-column prop="studentNo" label="学号" width="120" />
<el-table-column prop="studentName" label="姓名" width="100" />
<el-table-column prop="attDate" label="考勤日期" width="120" />
<el-table-column prop="checkInTime" label="签到时间" width="170" />
<el-table-column prop="checkOutTime" label="签退时间" width="170" />
<el-table-column prop="faceSimilarity" label="人脸相似度" width="110" align="center">
<template #default="{ row }">
<span>{{ (row.faceSimilarity * 100).toFixed(1) }}%</span>
</template>
</el-table-column>
<el-table-column prop="attStatus" label="考勤状态" width="120" align="center">
<template #default="{ row }">
<template v-if="editingId === row.id">
<el-select
ref="selectRef"
v-model="row.attStatus"
size="small"
style="width: 90px"
@change="(val) => handleStatusChange(row, val)"
@visible-change="(visible) => { if (!visible) editingId = null }"
>
<el-option label="未签到" :value="0" />
<el-option label="正常" :value="1" />
<el-option label="迟到" :value="2" />
<el-option label="缺勤" :value="3" />
<el-option label="早退" :value="4" />
<el-option label="请假" :value="5" />
</el-select>
</template>
<template v-else>
<el-tag
size="small"
:type="statusTagType(row.attStatus)"
style="cursor: pointer"
@click="startEditing(row)"
>
{{ statusLabel(row.attStatus) }}
</el-tag>
</template>
</template>
</el-table-column>
</el-table>
<el-pagination
v-model:current-page="pagination.current"
v-model:page-size="pagination.pageSize"
:total="total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
background
style="margin-top: 16px; justify-content: flex-end"
/>
</el-tab-pane>
</el-tabs>
<template #footer>
<el-button @click="emit('update:modelValue', false)">关闭</el-button>
<el-button type="primary" @click="batchExport"></el-button>
</template>
</el-dialog>
</template>
<script setup>
import { ref, reactive, watch, nextTick } from 'vue'
import { ElMessage } from 'element-plus'
import { getDetailPage, updateDetailStatus } from '@/api/attendance'
const props = defineProps({
modelValue: { type: Boolean, default: false },
detailData: { type: Object, default: () => ({}) }
})
const emit = defineEmits(['update:modelValue'])
const detailTab = ref('detail')
const loading = ref(false)
const detailList = ref([])
const total = ref(0)
const searchForm = reactive({
studentName: '',
attStatus: null
})
const pagination = reactive({
current: 1,
pageSize: 10
})
const statusMap = {
0: { label: '未签到', type: 'warning' },
1: { label: '正常', type: 'success' },
2: { label: '迟到', type: '' },
3: { label: '缺勤', type: 'danger' },
4: { label: '早退', type: '' },
5: { label: '请假', type: 'warning' }
}
const statusLabel = (val) => statusMap[val]?.label || '未知'
const statusTagType = (val) => statusMap[val]?.type || 'info'
//
const editingId = ref(null)
const selectRef = ref(null)
const startEditing = async (row) => {
editingId.value = row.id
await nextTick()
selectRef.value?.focus?.()
}
/** 更新单条考勤状态 */
const handleStatusChange = async (row, newStatus) => {
try {
const res = await updateDetailStatus(row.id, newStatus)
if (res?.code === 200) {
ElMessage.success('状态更新成功')
editingId.value = null
} else {
ElMessage.error(res?.msg || '更新失败')
fetchDetailList()
}
} catch {
ElMessage.error('更新失败')
fetchDetailList()
}
}
//
const selectedRows = ref([])
const handleSelectionChange = (rows) => {
selectedRows.value = rows
}
const fetchDetailList = async () => {
loading.value = true
try {
const params = {
current: pagination.current,
size: pagination.pageSize,
taskId: props.detailData.id
}
if (searchForm.studentName) params.studentName = searchForm.studentName
if (searchForm.attStatus !== null && searchForm.attStatus !== '') params.attStatus = searchForm.attStatus
const res = await getDetailPage(params)
if (res?.code === 200 && res.data) {
detailList.value = res.data.records || []
total.value = res.data.total || 0
}
} catch {
detailList.value = []
total.value = 0
} finally {
loading.value = false
}
}
const handleSearch = () => {
pagination.current = 1
fetchDetailList()
}
const handleReset = () => {
searchForm.studentName = ''
searchForm.attStatus = null
pagination.current = 1
fetchDetailList()
}
const batchExport = () => {
const list = selectedRows.value.length > 0 ? selectedRows.value : detailList.value
if (list.length === 0) {
ElMessage.warning('没有数据可导出')
return
}
// CSV
const headers = ['学号', '姓名', '考勤日期', '签到时间', '签退时间', '人脸相似度', '考勤状态']
const rows = list.map(row => [
row.studentNo,
row.studentName,
row.attDate,
row.checkInTime,
row.checkOutTime,
row.faceSimilarity ? (row.faceSimilarity * 100).toFixed(1) + '%' : '',
statusLabel(row.attStatus)
])
// CSV
const csvContent = [headers, ...rows]
.map(r => r.map(cell => `"${String(cell ?? '').replace(/"/g, '""')}"`).join(','))
.join('\n')
const BOM = '\uFEFF'
const blob = new Blob([BOM + csvContent], { type: 'text/csv;charset=utf-8;' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `考勤详情_${props.detailData.courseName || ''}_${new Date().toLocaleDateString()}.csv`
a.click()
URL.revokeObjectURL(url)
ElMessage.success(`已导出 ${list.length} 条记录`)
}
//
watch(() => props.modelValue, (val) => {
if (val) {
detailTab.value = 'detail'
pagination.current = 1
fetchDetailList()
}
})
//
watch(() => pagination.current, () => fetchDetailList())
watch(() => pagination.pageSize, () => {
pagination.current = 1
fetchDetailList()
})
</script>
<style lang="scss" scoped>
.detail-header {
display: flex;
gap: 24px;
padding: 12px 16px;
background: #f5f7fa;
border-radius: 8px;
margin-bottom: 16px;
font-size: 13px;
color: #525252;
}
.search-bar {
display: flex;
gap: 12px;
align-items: center;
margin-bottom: 16px;
}
</style>

@ -0,0 +1,237 @@
<template>
<div>
<!-- 筛选区 -->
<div class="filter-bar">
<el-date-picker
v-model="filters.attDate"
type="date"
placeholder="选择日期"
size="default"
/>
<el-button type="primary" :icon="Search" @click="handleSearch"></el-button>
<el-button @click="handleReset"></el-button>
</div>
<!-- 统计条 -->
<div class="summary-bar">
<div class="summary-item">
<span class="summary-label">总课程数</span>
<span class="summary-value">{{ tableData.length }}</span>
</div>
<div class="summary-item">
<span class="summary-label">应到总人次</span>
<span class="summary-value">{{ totalShould }}</span>
</div>
<div class="summary-item">
<span class="summary-label">实到总人次</span>
<span class="summary-value green">{{ totalActual }}</span>
</div>
<div class="summary-item">
<span class="summary-label">平均缺勤率</span>
<span class="summary-value red">{{ avgAbsentRate }}%</span>
</div>
</div>
<!-- 数据表格 -->
<el-table :data="tableData" stripe v-loading="loading">
<el-table-column prop="courseName" label="课程名称" min-width="150" />
<el-table-column prop="teacher" label="授课教师" width="100" align="center" />
<el-table-column prop="classroom" label="教室" width="120" align="center" />
<el-table-column prop="time" label="上课时间" width="320" align="center" sortable>
<template #default="{ row }">
<span v-html="row.time"></span>
</template>
</el-table-column>
<el-table-column prop="total" label="应到" width="70" align="center" />
<el-table-column prop="actual" label="实到" width="70" align="center" />
<el-table-column prop="absentCount" label="缺勤" width="70" align="center">
<template #default="{ row }">
<span :class="{ 'text-red': row.absentCount > 3 }">{{ row.absentCount }}</span>
</template>
</el-table-column>
<el-table-column prop="absentRate" label="缺勤率" width="90" align="center" sortable>
<template #default="{ row }">
<el-tag :type="getAbsentType(row.absentRate)" size="small">{{ row.absentRate }}%</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="140" align="center" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="$emit('showDetail', row)">详情</el-button>
<el-button link size="small" @click="handleExport(row)"></el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
v-model:current-page="pagination.current"
v-model:page-size="pagination.pageSize"
:total="total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
background
/>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { Search } from '@element-plus/icons-vue'
import { getRecordPage, getDetailPage } from '@/api/attendance'
import { useNameMaps } from '@/composables/useNameMaps'
defineEmits(['showDetail'])
const { loadNameMaps, courseName, teacherName, className, roomName } = useNameMaps()
const loading = ref(false)
const filters = reactive({ attDate: null })
const pagination = reactive({ current: 1, pageSize: 10 })
const total = ref(0)
/** 将接口记录转为表格行数据 */
const mapRecord = (item) => ({
courseName: courseName(item.courseId),
teacher: teacherName(item.teacherId),
classroom: roomName(item.classroomId),
className: className(item.classId),
time: `${item.startTime || ''}${item.endTime || ''}`,
total: item.totalCount,
actual: item.actualCount,
absentCount: item.absentCount,
absentRate: item.absentRate ?? 0,
id: item.id
})
const tableData = ref([])
const fetchData = async () => {
loading.value = true
try {
const params = {
current: pagination.current,
size: pagination.pageSize
}
if (filters.attDate) {
const d = new Date(filters.attDate)
params.attDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
const res = await getRecordPage(params)
const { records, total: totalCount } = res.data
if (Array.isArray(records)) {
tableData.value = records.map(mapRecord)
}
total.value = totalCount ?? 0
} catch {
//
} finally {
loading.value = false
}
}
//
watch(() => pagination.current, () => fetchData())
watch(() => pagination.pageSize, () => {
pagination.current = 1
fetchData()
})
onMounted(async () => {
await loadNameMaps()
fetchData()
})
const totalShould = computed(() => tableData.value.reduce((s, r) => s + r.total, 0))
const totalActual = computed(() => tableData.value.reduce((s, r) => s + r.actual, 0))
const avgAbsentRate = computed(() => {
return tableData.value.length
? (tableData.value.reduce((s, r) => s + r.absentRate, 0) / tableData.value.length).toFixed(1)
: 0
})
const getAbsentType = (rate) => (rate >= 10 ? 'danger' : rate > 5 ? 'warning' : 'success')
const handleSearch = () => {
pagination.current = 1
fetchData()
}
const handleReset = () => {
Object.assign(filters, { attDate: null })
pagination.current = 1
fetchData()
}
const handleExport = async () => {
try {
const res = await getDetailPage({ current: 1, size: 9999 })
const list = res?.data?.records || []
if (list.length === 0) {
ElMessage.warning('暂无考勤详情数据')
return
}
const statusLabels = { 0: '未签到', 1: '正常', 2: '迟到', 3: '缺勤', 4: '早退', 5: '请假' }
const headers = ['学号', '姓名', '考勤日期', '签到时间', '签退时间', '人脸相似度', '考勤状态']
const rows = list.map(r => [
r.studentNo, r.studentName, r.attDate, r.checkInTime, r.checkOutTime,
r.faceSimilarity ? (r.faceSimilarity * 100).toFixed(1) + '%' : '',
statusLabels[r.attStatus] || '未知'
])
const csv = [headers, ...rows]
.map(r => r.map(c => `"${String(c ?? '').replace(/"/g, '""')}"`).join(','))
.join('\n')
const blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8;' })
const a = document.createElement('a')
a.href = URL.createObjectURL(blob)
a.download = `考勤明细_${new Date().toLocaleDateString()}.csv`
a.click()
URL.revokeObjectURL(a.href)
ElMessage.success(`已导出 ${list.length} 条记录`)
} catch {
ElMessage.error('导出失败')
}
}
</script>
<style lang="scss" scoped>
.filter-bar {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 14px;
}
.summary-bar {
display: flex;
gap: 40px;
padding: 12px 16px;
margin-bottom: 14px;
background: #f5f7fa;
border-radius: 6px;
}
.summary-item {
display: flex;
flex-direction: column;
gap: 2px;
}
.summary-label {
font-size: 12px;
color: #bfbfbf;
}
.summary-value {
font-size: 18px;
font-weight: 700;
color: #262626;
&.green { color: #52c41a; }
&.red { color: #f5222d; }
}
.text-red {
color: #f5222d;
font-weight: 600;
}
</style>

@ -0,0 +1,94 @@
<template>
<div class="chart-card">
<div class="chart-header">
<h3>近7天出勤趋势</h3>
<el-tag size="small" type="success">实时</el-tag>
</div>
<div ref="trendChartRef" class="chart-body"></div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import * as echarts from 'echarts'
import { getTrend } from '@/api/dashboard'
const trendChartRef = ref(null)
let trendChart = null
const initTrendChart = (dates = [], values = []) => {
if (!trendChartRef.value) return
//
if (!trendChart) {
trendChart = echarts.init(trendChartRef.value)
}
trendChart.setOption({
tooltip: { trigger: 'axis' },
grid: { top: 20, right: 20, bottom: 20, left: 40 },
xAxis: { type: 'category', data: dates, axisTick: { show: false } },
yAxis: { type: 'value', min: 0, max: 100, axisLabel: { formatter: '{value}%' } },
series: [{
data: values,
type: 'line', smooth: true, symbol: 'circle', symbolSize: 6,
lineStyle: { color: '#52c41a', width: 3 }, itemStyle: { color: '#52c41a' },
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: 'rgba(82,196,26,0.15)' },
{ offset: 1, color: 'rgba(82,196,26,0.02)' }
])
}
}]
})
}
const fetchTrendData = async () => {
try {
const res = await getTrend()
const list = res.data
if (Array.isArray(list) && list.length > 0) {
const dates = list.map(item => item.date)
const values = list.map(item => item.attendanceRate)
initTrendChart(dates, values)
}
} catch {
}
}
const handleResize = () => trendChart?.resize()
onMounted(() => {
fetchTrendData()
window.addEventListener('resize', handleResize)
})
onUnmounted(() => {
trendChart?.dispose()
window.removeEventListener('resize', handleResize)
})
</script>
<style lang="scss" scoped>
.chart-card {
background: #fff;
border-radius: 8px;
padding: 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
.chart-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
h3 {
font-size: 16px;
font-weight: 600;
color: #262626;
}
}
.chart-body {
height: 240px;
}
</style>

@ -0,0 +1,168 @@
<template>
<div class="chart-card">
<div class="chart-header">
<h3>各班级出勤率对比</h3>
</div>
<div v-if="classRank && classRank.length > 0" class="class-rank">
<div v-for="(item, index) in classRank" :key="index" class="rank-item">
<span class="rank-num" :class="{ top: index < 3 }">{{ pageOffset + index + 1 }}</span>
<span class="rank-name">{{ item.className }}</span>
<div class="rank-bar-wrap">
<div
class="rank-bar"
:style="{ width: item.attendanceRate + '%', background: index < 3 ? '#52c41a' : '#1890ff' }"
></div>
</div>
<span class="rank-value">{{ item.attendanceRate }}%</span>
</div>
</div>
<div v-else class="no-data">
<el-empty description="暂无数据"></el-empty>
</div>
<el-pagination
v-if="total > 0"
v-model:current-page="current"
v-model:page-size="size"
:total="total"
layout="prev, pager, next"
background
size="small"
style="margin-top: 16px; justify-content: center"
/>
</div>
</template>
<script setup>
import { ref, computed, watch, onMounted } from 'vue'
import { getRanking } from '@/api/dashboard'
const classRank = ref([])
const current = ref(1)
const size = ref(8)
const total = ref(0)
const pages = ref(0)
const pageOffset = computed(() => (current.value - 1) * size.value)
const fetchRanking = async () => {
try {
const res = await getRanking({ current: current.value, size: size.value })
const data = res.data
if (data && data.records && data.records.length > 0) {
classRank.value = data.records
total.value = data.total ?? 0
pages.value = data.pages ?? 0
} else {
classRank.value = []
total.value = 0
}
} catch {
classRank.value = []
total.value = 0
}
}
onMounted(() => {
fetchRanking()
})
watch(() => current.value, () => fetchRanking())
watch(() => size.value, () => {
current.value = 1
fetchRanking()
})
</script>
<style lang="scss" scoped>
.chart-card {
background: #fff;
border-radius: 8px;
padding: 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
.chart-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
h3 {
font-size: 16px;
font-weight: 600;
color: #262626;
}
}
.class-rank {
display: flex;
flex-direction: column;
gap: 10px;
}
.rank-item {
display: flex;
align-items: center;
gap: 10px;
}
.rank-num {
width: 22px;
height: 22px;
border-radius: 6px;
background: #f0f0f0;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 600;
color: #525252;
flex-shrink: 0;
&.top {
background: #52c41a;
color: #fff;
}
}
.rank-name {
width: 140px;
font-size: 13px;
color: #262626;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex-shrink: 0;
}
.rank-bar-wrap {
flex: 1;
height: 8px;
background: #f0f0f0;
border-radius: 4px;
overflow: hidden;
}
.rank-bar {
height: 100%;
border-radius: 4px;
transition: width 0.6s ease;
}
.rank-value {
font-size: 13px;
font-weight: 600;
color: #262626;
width: 45px;
text-align: right;
flex-shrink: 0;
}
.no-data {
min-height: 100px;
display: flex;
align-items: center;
justify-content: center;
}
</style>

@ -0,0 +1,231 @@
<template>
<div class="page-container">
<!-- 核心数据区 -->
<div class="stats-grid">
<DataCard
label="课程平均出勤率"
:value="stats.attendanceRate"
unit="%"
icon="UserFilled"
iconColor="#52c41a"
iconBg="#e8f9e0"
:trend="2.5"
:date="currentDate"
@click="activeTab = 'manage'"
/>
<DataCard
label="教室平均使用率"
:value="stats.classroomUsage"
unit="%"
icon="OfficeBuilding"
iconColor="#1890ff"
iconBg="#e8f4ff"
:trend="-1.2"
@click="$router.push('/bigscreen')"
/>
<DataCard
label="异常行为预警数"
:value="stats.warningCount"
unit="次"
icon="WarningFilled"
iconColor="#f5222d"
iconBg="#fff1f0"
:trend="-15.0"
@click="$router.push('/behavior')"
/>
</div>
<!-- 快捷入口 -->
<div class="quick-actions">
<el-button :icon="VideoCamera" @click="$router.push('/bigscreen')"></el-button>
</div>
<!-- 图表区 -->
<div class="charts-row">
<AttendanceTrendChart />
<ClassRanking />
</div>
<!-- 考勤记录区 -->
<div class="records-card">
<el-tabs v-model="activeTab">
<el-tab-pane label="考勤概览" name="overview">
<div class="records-header">
<h3>最近考勤记录</h3>
</div>
<el-table :data="recentRecords" stripe>
<el-table-column prop="courseName" label="课程名称" min-width="150" />
<el-table-column prop="teacher" label="授课教师" width="100" align="center" />
<el-table-column prop="classroom" label="教室" width="120" align="center" />
<el-table-column prop="time" label="上课时间" width="320" sortable align="center">
<template #default="{ row }">
<span v-html="row.time"></span>
</template>
</el-table-column>
<el-table-column prop="total" label="应到" width="70" align="center" />
<el-table-column prop="actual" label="实到" width="70" align="center" />
<el-table-column prop="absentRate" label="缺勤率" width="90" align="center">
<template #default="{ row }">
<el-tag :type="row.absentRate > 10 ? 'danger' : 'success'" size="small">
{{ row.absentRate }}%
</el-tag>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
<el-tab-pane label="考勤管理" name="manage">
<AttendanceManage @showDetail="showDetail" />
</el-tab-pane>
</el-tabs>
</div>
<!-- 详情弹窗 -->
<AttendanceDetail v-model="detailVisible" :detail-data="currentDetail" />
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { getStats } from '@/api/dashboard'
import { getRecordPage } from '@/api/attendance'
import { useNameMaps } from '@/composables/useNameMaps'
import DataCard from '@/components/DataCard.vue'
import AttendanceTrendChart from './components/AttendanceTrendChart.vue'
import ClassRanking from './components/ClassRanking.vue'
import AttendanceManage from './components/AttendanceManage.vue'
import AttendanceDetail from './components/AttendanceDetail.vue'
import { VideoCamera } from '@element-plus/icons-vue'
/** 将接口记录转为表格行数据 */
const mapRecord = (item) => ({
courseName: courseName(item.courseId),
teacher: teacherName(item.teacherId),
classroom: roomName(item.classroomId),
className: className(item.classId),
time: `${item.startTime || ''} - ${item.endTime || ''}`,
total: item.totalCount,
actual: item.actualCount,
absentCount: item.absentCount,
absentRate: item.absentRate ?? 0
})
const { loadNameMaps, courseName, teacherName, className, roomName } = useNameMaps()
const currentDate = computed(() => {
const d = new Date()
return `${d.getFullYear()}${d.getMonth() + 1}${d.getDate()}`
})
// ===== =====
const stats = ref({ attendanceRate: 0, classroomUsage: 0, warningCount: 0 })
const fetchStats = async () => {
try {
const res = await getStats()
const data = res.data
stats.value = {
attendanceRate: data.attendanceRate ?? 0,
classroomUsage: data.classroomUsage ?? 0,
warningCount: data.warningCount ?? 0
}
} catch {
//
}
}
onMounted(async () => {
await loadNameMaps()
fetchStats()
fetchRecentRecords()
})
// ===== tabs =====
const activeTab = ref('overview')
// ===== =====
const recentRecords = ref([])
const fetchRecentRecords = async () => {
try {
const res = await getRecordPage({ current: 1, size: 10, taskStatus: 2 })
const { records } = res.data
if (Array.isArray(records)) {
recentRecords.value = records.map(mapRecord)
}
} catch {
//
}
}
// ===== =====
const detailVisible = ref(false)
const currentDetail = ref({})
const showDetail = (row) => {
currentDetail.value = row
detailVisible.value = true
}
// ===== =====
const handleExport = (row) => ElMessage.success(`正在导出 ${row.courseName} 考勤明细...`)
</script>
<style lang="scss" scoped>
.quick-actions {
display: flex;
gap: 12px;
margin-bottom: 20px;
.el-button {
height: 40px;
border-radius: 8px;
font-size: 14px;
&:not(.el-button--primary) {
border-color: #d9d9d9;
color: #525252;
&:hover {
color: #52c41a;
border-color: #52c41a;
}
}
}
}
.charts-row {
display: grid;
grid-template-columns: 1.5fr 1fr;
gap: 16px;
margin-bottom: 20px;
}
.records-card {
background: #fff;
border-radius: 8px;
padding: 8px 20px 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
.records-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
h3 {
font-size: 16px;
font-weight: 600;
color: #262626;
}
}
@media (max-width: 1024px) {
.charts-row {
grid-template-columns: 1fr;
}
}
</style>

@ -0,0 +1,390 @@
<template>
<div class="page-container fade-in-up">
<div class="page-header">
<h2 class="page-title">历史记录查询</h2>
<p class="page-subtitle">查询课程考勤与行为识别历史记录</p>
</div>
<!-- 查询区 -->
<div class="filter-bar">
<el-input
v-model="query.courseName"
placeholder="输入课程名称"
:prefix-icon="Search"
clearable
size="default"
style="width: 200px"
/>
<el-date-picker v-model="query.date" type="date" placeholder="选择日期" size="default" value-format="YYYY-MM-DD" />
<el-button type="primary" :icon="Search" :loading="loading" @click="doSearch"></el-button>
<el-button :icon="RefreshRight" @click="doReset"></el-button>
</div>
<!-- 查询结果 -->
<div class="result-section">
<div class="section-header">
<h3>查询结果</h3>
<span class="result-count"> {{ pagination.total }} 条记录</span>
</div>
<div v-loading="loading" class="image-masonry">
<div
v-for="record in records"
:key="record.id"
class="masonry-item"
@click="viewDetail(record)"
>
<div class="masonry-thumb">
<div class="water-glass">
<div
class="water-fill"
:style="{
height: record.attendanceRate + '%',
background: getWaterGradient(record.attendanceRate)
}"
>
<svg class="water-wave" viewBox="0 0 200 24" preserveAspectRatio="none">
<path d="M0,12 C40,4 60,20 100,12 C140,4 160,20 200,12 L200,24 L0,24 Z" :fill="getBorderColor(record.attendanceRate)" />
</svg>
<svg class="water-wave water-wave-back" viewBox="0 0 200 24" preserveAspectRatio="none">
<path d="M0,12 C40,18 60,6 100,12 C140,18 160,6 200,12 L200,24 L0,24 Z" :fill="getBorderColor(record.attendanceRate)" opacity="0.4" />
</svg>
</div>
<span class="water-value">{{ record.attendanceRate }}%</span>
</div>
<span class="water-label">出勤率</span>
</div>
<div class="masonry-info">
<span class="masonry-course" :title="record.courseName">{{ record.courseName }}</span>
<span class="masonry-time">
<el-icon :size="12"><Clock /></el-icon>
{{ record.attDate }}
</span>
<div class="masonry-stats">
<span>应到 {{ record.totalCount }}</span>
<el-divider direction="vertical" />
<span>实到 {{ record.actualCount }}</span>
</div>
<span class="masonry-absent">
缺勤 {{ record.absentCount }}
</span>
<div class="masonry-extra">
<span v-if="record.className" class="masonry-tag">{{ record.className }}</span>
<span v-if="record.classroomName" class="masonry-tag">{{ record.classroomName }}</span>
</div>
</div>
</div>
</div>
<div v-if="records.length" class="pagination-wrap">
<el-pagination
v-model:current-page="pagination.current"
:total="pagination.total"
:page-size="pagination.size"
size="small"
background
layout="prev, pager, next"
@current-change="doSearch"
/>
</div>
<el-empty v-if="!loading && records.length === 0" description="暂无数据,请调整查询条件" />
</div>
<!-- 详情弹窗 -->
<el-dialog v-model="detailVisible" title="考勤详情" width="520px" destroy-on-close>
<div class="detail-container" v-if="currentRecord">
<el-descriptions :column="2" border size="small">
<el-descriptions-item label="考勤日期">{{ currentRecord.attDate }}</el-descriptions-item>
<el-descriptions-item label="课程ID">{{ currentRecord.courseId }}</el-descriptions-item>
<el-descriptions-item label="开始时间">{{ currentRecord.startTime }}</el-descriptions-item>
<el-descriptions-item label="结束时间">{{ currentRecord.endTime }}</el-descriptions-item>
<el-descriptions-item label="应到人数">{{ currentRecord.totalCount }}</el-descriptions-item>
<el-descriptions-item label="实到人数">{{ currentRecord.actualCount }}</el-descriptions-item>
<el-descriptions-item label="缺勤人数">
<el-tag type="danger" size="small">{{ currentRecord.absentCount }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="迟到人数">{{ currentRecord.lateCount }}</el-descriptions-item>
<el-descriptions-item label="早退人数">{{ currentRecord.leaveEarlyCount }}</el-descriptions-item>
<el-descriptions-item label="出勤率">
<el-tag :type="getRateType(currentRecord.attendanceRate)" size="small">
{{ currentRecord.attendanceRate }}%
</el-tag>
</el-descriptions-item>
</el-descriptions>
</div>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { Search, RefreshRight, Clock } from '@element-plus/icons-vue'
import { getAttendanceHistory } from '@/api/history'
const loading = ref(false)
const detailVisible = ref(false)
const currentRecord = ref(null)
const query = reactive({
courseName: '',
date: null
})
const pagination = reactive({
current: 1,
size: 20,
total: 0
})
const records = ref([])
/** 查询 */
const doSearch = async () => {
loading.value = true
try {
const params = {
current: pagination.current,
size: pagination.size,
courseName: query.courseName || undefined,
attDate: query.date || undefined
}
const res = await getAttendanceHistory(params)
if (res && res.data) {
records.value = res.data.records || []
pagination.current = res.data.current
pagination.total = res.data.total
pagination.size = res.data.size
}
} catch {
//
} finally {
loading.value = false
}
}
/** 重置 */
const doReset = () => {
query.courseName = ''
query.date = null
pagination.current = 1
doSearch()
}
/** 根据出勤率获取水波颜色 */
const getBorderColor = (rate) => {
if (rate >= 95) return '#52c41a'
if (rate >= 85) return '#faad14'
return '#ff4d4f'
}
/** 根据出勤率获取水面渐变 */
const getWaterGradient = (rate) => {
if (rate >= 95) return 'linear-gradient(180deg, rgba(82,196,26,0.3) 0%, rgba(82,196,26,0.65) 100%)'
if (rate >= 85) return 'linear-gradient(180deg, rgba(250,173,20,0.3) 0%, rgba(250,173,20,0.65) 100%)'
return 'linear-gradient(180deg, rgba(255,77,79,0.3) 0%, rgba(255,77,79,0.65) 100%)'
}
/** 根据出勤率获取 tag 类型 */
const getRateType = (rate) => {
if (rate >= 95) return 'success'
if (rate >= 85) return 'warning'
return 'danger'
}
/** 查看详情 */
const viewDetail = (record) => {
currentRecord.value = record
detailVisible.value = true
}
onMounted(() => {
doSearch()
})
</script>
<style lang="scss" scoped>
.result-section {
background: #ffffff;
border-radius: 8px;
padding: 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
h3 {
font-size: 16px;
font-weight: 600;
color: #262626;
}
}
.result-count {
font-size: 12px;
color: #bfbfbf;
}
}
.image-masonry {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 12px;
}
.masonry-item {
border-radius: 8px;
overflow: hidden;
cursor: pointer;
transition: all 0.3s ease;
border: 1px solid #f0f0f0;
&:hover {
border-color: #52c41a;
box-shadow: 0 4px 12px rgba(82, 196, 26, 0.12);
transform: translateY(-2px);
}
}
.masonry-thumb {
background: #f9fafb;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 18px 0 12px;
}
/* ========== 水球玻璃 ========== */
.water-glass {
width: 110px;
height: 110px;
border-radius: 50%;
position: relative;
overflow: hidden;
background: rgba(255, 255, 255, 0.25);
border: 3px solid rgba(255, 255, 255, 0.5);
box-shadow:
0 0 15px rgba(0, 0, 0, 0.08),
inset 0 2px 8px rgba(255, 255, 255, 0.4),
inset 0 -2px 8px rgba(0, 0, 0, 0.05);
}
.water-fill {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 0%;
transition: height 0.8s cubic-bezier(0.25, 0.46, 0.45, 0.94);
overflow: visible;
}
.water-wave {
position: absolute;
top: -16px;
left: 0;
width: 200%;
height: 20px;
animation: wave 3s linear infinite;
}
.water-wave-back {
animation: wave 4s linear infinite reverse;
}
@keyframes wave {
0% { transform: translateX(0); }
100% { transform: translateX(-50%); }
}
.water-label {
font-size: 12px;
color: #8c8c8c;
margin-top: 4px;
}
.water-value {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 24px;
font-weight: 700;
color: #262626;
z-index: 2;
pointer-events: none;
text-shadow: 0 1px 2px rgba(255, 255, 255, 0.7);
}
.masonry-info {
padding: 8px 12px;
display: flex;
flex-direction: column;
gap: 4px;
}
.masonry-course {
font-size: 14px;
font-weight: 600;
color: #262626;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.masonry-time {
font-size: 13px;
color: #525252;
display: flex;
align-items: center;
gap: 4px;
font-family: monospace;
}
.masonry-stats {
font-size: 13px;
color: #bfbfbf;
display: flex;
align-items: center;
gap: 4px;
}
.masonry-absent {
font-size: 13px;
color: #ff4d4f;
}
.masonry-extra {
display: flex;
flex-direction: column;
gap: 2px;
margin-top: 2px;
}
.masonry-tag {
font-size: 12px;
color: #8c8c8c;
background: #f5f5f5;
border-radius: 3px;
padding: 1px 6px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.pagination-wrap {
display: flex;
justify-content: center;
margin-top: 16px;
}
.detail-container {
.el-descriptions {
margin-top: 8px;
}
}
</style>

File diff suppressed because it is too large Load Diff

@ -0,0 +1,269 @@
<template>
<div class="page-container fade-in-up">
<div class="page-header">
<h2 class="page-title">班级信息</h2>
<p class="page-subtitle">管理学校班级信息包括班级基础信息与课程安排</p>
</div>
<div class="filter-bar">
<el-input v-model="searchKey" placeholder="搜索班级名称..." :prefix-icon="Search" clearable size="default" style="width: 240px" />
<el-date-picker v-model="gradeFilter" type="year" placeholder="年级筛选" clearable size="default" style="width: 160px" value-format="YYYY" />
<el-button type="primary" :icon="Plus" @click="showDialog()"></el-button>
<el-button :icon="Delete" :disabled="selectedRows.length === 0" @click="batchDelete"></el-button>
</div>
<div class="data-table-card">
<el-table :data="classes" stripe @selection-change="handleSelection" row-key="id">
<el-table-column type="selection" width="45" />
<el-table-column prop="className" label="班级名称" min-width="220" />
<el-table-column prop="grade" label="年级" width="110" align="center" />
<el-table-column prop="major" label="专业" width="160" />
<el-table-column prop="studentCount" label="学生人数" width="100" align="center" />
<el-table-column label="班主任" width="100" align="center">
<template #default="{ row }">
{{ teacherOptions.find(t => t.id === row.headteacherId)?.name || '—' }}
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="90" align="center">
<template #default="{ row }">
<el-tag :type="row.status === 0 ? 'success' : 'info'" size="small">
{{ row.status === 0 ? '在读' : '毕业' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="200" align="center" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" :icon="Edit" @click="showDialog(row)"></el-button>
<el-button link type="info" size="small" :icon="View" :disabled="row.status === 1" @click="showCourses(row)"></el-button>
<el-button link type="danger" size="small" :icon="Delete" @click="deleteRow(row)"></el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
v-model:current-page="pageCurrent"
v-model:page-size="pageSize"
:total="total"
:page-sizes="[10, 20, 50]"
size="small"
background
layout="total, sizes, prev, pager, next"
style="justify-content: flex-end; margin-top: 16px"
@current-change="fetchClasses"
@size-change="onSizeChange"
/>
</div>
<!-- 添加/编辑班级弹窗 -->
<el-dialog v-model="dialogVisible" :title="editing ? '编辑班级信息' : '添加班级'" width="520px" align-center destroy-on-close>
<el-form ref="formRef" :model="form" :rules="rules" label-width="90px" label-position="left">
<el-form-item label="班级名称" prop="className">
<el-input v-model="form.className" placeholder="请输入班级全称" />
</el-form-item>
<el-form-item label="年级" prop="grade">
<el-date-picker v-model="form.grade" type="year" placeholder="请选择入学年份" style="width: 100%" value-format="YYYY" />
</el-form-item>
<el-form-item label="专业" prop="major">
<el-input v-model="form.major" placeholder="请输入专业名称" />
</el-form-item>
<el-form-item label="班主任" prop="headteacherId">
<el-select v-model="form.headteacherId" placeholder="请选择班主任" clearable filterable style="width: 100%">
<el-option v-for="t in teacherOptions" :key="t.id" :label="t.name" :value="t.id" />
</el-select>
</el-form-item>
<el-form-item label="学生人数">
<el-input-number v-model="form.studentCount" :min="0" :max="200" style="width: 100%" />
</el-form-item>
<el-form-item label="状态">
<el-radio-group v-model="form.status">
<el-radio :value="0">在读</el-radio>
<el-radio :value="1">毕业</el-radio>
</el-radio-group>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="saveClass"></el-button>
</template>
</el-dialog>
<!-- 课程安排弹窗 -->
<ClassCourseDialog v-model="courseDialogVisible" :class-data="currentClass" />
</div>
</template>
<script setup>
import { ref, onMounted, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Search, Plus, Delete, Edit, View } from '@element-plus/icons-vue'
import { addClass, updateClass, getTeacherList, getClasses, deleteClass } from '@/api/info'
import ClassCourseDialog from './components/ClassCourseDialog.vue'
const searchKey = ref('')
const gradeFilter = ref('')
const pageCurrent = ref(1)
const pageSize = ref(10)
const dialogVisible = ref(false)
const editing = ref(false)
const selectedRows = ref([])
const formRef = ref(null)
const teacherOptions = ref([])
const rules = {
className: [{ required: true, message: '请输入班级名称', trigger: 'blur' }],
grade: [{ required: true, message: '请选择入学年份', trigger: 'change' }],
major: [{ required: true, message: '请输入专业名称', trigger: 'blur' }],
headteacherId: [{ required: true, message: '请选择班主任', trigger: 'change' }]
}
const form = ref({
id: '',
className: '',
grade: '',
major: '',
headteacherId: '',
studentCount: 0,
status: 0
})
const classes = ref([])
const total = ref(0)
const handleSelection = (rows) => { selectedRows.value = rows }
const showDialog = (row) => {
if (row) {
editing.value = true
form.value = { ...row }
} else {
editing.value = false
form.value = { id: '', className: '', grade: '', major: '', headteacherId: '', studentCount: 0, status: 0 }
}
dialogVisible.value = true
}
const saveClass = async () => {
if (!formRef.value) return
try {
await formRef.value.validate()
if (editing.value) {
await updateClass({
id: form.value.id,
className: form.value.className,
grade: `${form.value.grade}`,
major: form.value.major,
studentCount: form.value.studentCount,
headteacherId: form.value.headteacherId,
status: form.value.status
})
ElMessage.success('编辑成功')
fetchClasses()
} else {
await addClass({
className: form.value.className,
grade: `${form.value.grade}`,
major: form.value.major,
studentCount: form.value.studentCount,
headteacherId: form.value.headteacherId,
status: form.value.status
})
ElMessage.success('添加成功')
fetchClasses()
}
dialogVisible.value = false
} catch {
ElMessage.warning('请完善表单信息')
}
}
const deleteRow = (row) => {
ElMessageBox.confirm(`确认删除班级 "${row.className}"`, '提示', { type: 'warning' })
.then(async () => {
await deleteClass([row.id])
ElMessage.success('删除成功')
fetchClasses()
})
.catch(() => {})
}
const batchDelete = () => {
ElMessageBox.confirm(`确认删除选中的 ${selectedRows.value.length} 个班级?`, '批量删除', { type: 'warning' })
.then(async () => {
const ids = selectedRows.value.map(r => r.id)
await deleteClass(ids)
ElMessage.success('批量删除成功')
fetchClasses()
})
.catch(() => {})
}
//
const fetchClasses = async () => {
try {
const res = await getClasses({
current: pageCurrent.value,
size: pageSize.value,
keyword: searchKey.value,
grade: gradeFilter.value?`${gradeFilter.value}`:''
})
if (res.code === 200) {
classes.value = res.data.records || []
total.value = res.data.total || 0
}
} catch {
ElMessage.error('获取班级列表失败')
}
}
const onSizeChange = () => {
pageCurrent.value = 1
fetchClasses()
}
//
const fetchTeachers = async () => {
try {
const res = await getTeacherList()
if (res.code === 200) {
teacherOptions.value = res.data || []
}
} catch {
//
}
}
//
watch(searchKey, () => {
pageCurrent.value = 1
fetchClasses()
})
//
watch(gradeFilter, () => {
pageCurrent.value = 1
fetchClasses()
})
onMounted(() => {
fetchClasses()
fetchTeachers()
})
// ========== ==========
const courseDialogVisible = ref(false)
const currentClass = ref(null)
const showCourses = (row) => {
currentClass.value = row
courseDialogVisible.value = true
}
</script>
<style lang="scss" scoped>
.data-table-card {
background: #ffffff;
border-radius: 8px;
padding: 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
</style>

@ -0,0 +1,427 @@
<template>
<el-dialog v-model="visible" :title="`${classData?.className || ''} — 课程安排`" width="850px" align-center destroy-on-close @closed="emit('closed')">
<div v-loading="loading" style="min-height: 200px">
<div class="toolbar">
<div class="toolbar-left">
<el-button type="primary" :icon="Plus" size="small" @click="showAddDialog"></el-button>
<el-button type="danger" :icon="Delete" size="small" :disabled="selectedIds.length === 0" @click="batchDelete"></el-button>
</div>
<span v-if="selectedIds.length > 0" class="toolbar-tip"> {{ selectedIds.length }} </span>
</div>
<el-table ref="tableRef" :data="scheduleList" stripe border size="small" empty-text="" @selection-change="onSelectionChange">
<el-table-column type="selection" width="40" />
<el-table-column label="课程名称" min-width="180">
<template #default="{ row }">{{ getCourseName(row.courseId) }}</template>
</el-table-column>
<el-table-column label="授课教师" width="110">
<template #default="{ row }">{{ getTeacherName(row.teacherId) }}</template>
</el-table-column>
<el-table-column label="星期" width="80" align="center">
<template #default="{ row }">{{ weekLabel[row.weekDay] || row.weekDay }}</template>
</el-table-column>
<el-table-column label="学期" width="120" align="center">
<template #default="{ row }">{{ row.semester }}</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="90" align="center">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'info'" size="small">{{ row.status === 1 ? '进行中' : '已结束' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="150" align="center" fixed="right">
<template #default="{ row }">
<el-button type="primary" link size="small" @click="showDetail(row)"></el-button>
<el-button type="primary" link size="small" @click="showEditDialog(row)"></el-button>
<el-button type="danger" link size="small" @click="handleDelete(row.id)"></el-button>
</template>
</el-table-column>
</el-table>
</div>
<template #footer>
<el-button @click="visible = false">关闭</el-button>
</template>
<!-- 新增/编辑课程安排弹窗 -->
<el-dialog v-model="addVisible" :title="isEdit ? '编辑课程安排' : '新增课程安排'" width="500px" append-to-body destroy-on-close @closed="onFormDialogClosed">
<el-form ref="addFormRef" :model="addForm" :rules="addRules" label-width="90px" label-position="left">
<el-form-item label="课程名称" prop="courseId">
<el-select v-model="addForm.courseId" placeholder="请选择课程" filterable style="width: 100%">
<el-option v-for="c in courseOptions" :key="c.id" :label="c.courseName" :value="c.id" />
</el-select>
</el-form-item>
<el-form-item label="授课老师" prop="teacherId">
<el-select v-model="addForm.teacherId" placeholder="请选择老师" filterable style="width: 100%">
<el-option v-for="t in teacherOptions" :key="t.id" :label="t.name" :value="t.id" />
</el-select>
</el-form-item>
<el-form-item label="星期几" prop="weekDay">
<el-select v-model="addForm.weekDay" placeholder="请选择星期" style="width: 100%">
<el-option v-for="w in weekdayOptions" :key="w.value" :label="w.label" :value="w.value" />
</el-select>
</el-form-item>
<el-form-item label="教学楼" prop="buildingId">
<el-select v-model="addForm.buildingId" placeholder="请选择教学楼" @change="onBuildingChange" style="width: 100%">
<el-option v-for="b in buildingOptions" :key="b.id" :label="b.buildingName" :value="b.id" />
</el-select>
</el-form-item>
<el-form-item label="教室" prop="classroomId">
<el-select v-model="addForm.classroomId" placeholder="请选择教室" filterable style="width: 100%">
<el-option v-for="r in filteredRoomOptions" :key="r.id" :label="r.name" :value="r.id" />
</el-select>
</el-form-item>
<el-form-item label="开始节次" prop="startSection">
<el-input-number v-model="addForm.startSection" :min="1" :max="12" style="width: 100%" />
</el-form-item>
<el-form-item label="结束节次" prop="endSection">
<el-input-number v-model="addForm.endSection" :min="1" :max="12" style="width: 100%" />
</el-form-item>
<el-form-item label="开始周" prop="startWeek">
<el-input-number v-model="addForm.startWeek" :min="1" :max="20" style="width: 100%" />
</el-form-item>
<el-form-item label="结束周" prop="endWeek">
<el-input-number v-model="addForm.endWeek" :min="1" :max="20" style="width: 100%" />
</el-form-item>
<el-form-item label="上课时间" prop="firstClassTime">
<el-date-picker v-model="addForm.firstClassTime" type="datetime" placeholder="请选择上课时间" value-format="YYYY-MM-DD HH:mm" style="width: 100%" />
</el-form-item>
<el-form-item label="学期" prop="semester">
<el-input v-model="addForm.semester" placeholder="请输入学期2024-2025-1" style="width: 100%" />
</el-form-item>
<el-form-item label="状态" prop="status">
<el-radio-group v-model="addForm.status">
<el-radio :value="1">启用</el-radio>
<el-radio :value="0">结束</el-radio>
</el-radio-group>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="addVisible = false">取消</el-button>
<el-button type="primary" :loading="addLoading" @click="saveSchedule"></el-button>
</template>
</el-dialog>
<!-- 课程安排详情弹窗 -->
<el-dialog v-model="detailVisible" title="课程安排详情" width="600px" append-to-body destroy-on-close>
<el-descriptions :column="2" border v-if="detailData">
<el-descriptions-item label="课程名称">{{ getCourseName(detailData.courseId) }}</el-descriptions-item>
<el-descriptions-item label="授课教师">{{ getTeacherName(detailData.teacherId) }}</el-descriptions-item>
<el-descriptions-item label="星期">{{ weekLabel[detailData.weekDay] || detailData.weekDay }}</el-descriptions-item>
<el-descriptions-item label="学期">{{ detailData.semester }}</el-descriptions-item>
<el-descriptions-item label="开始节次">{{ detailData.startSection }}</el-descriptions-item>
<el-descriptions-item label="结束节次">{{ detailData.endSection }}</el-descriptions-item>
<el-descriptions-item label="开始周">{{ detailData.startWeek }}</el-descriptions-item>
<el-descriptions-item label="结束周">{{ detailData.endWeek }}</el-descriptions-item>
<el-descriptions-item label="上课时间">{{ detailData.firstClassTime }}</el-descriptions-item>
<el-descriptions-item label="教学楼">{{ getBuildingName(detailData.classroomId) }}</el-descriptions-item>
<el-descriptions-item label="教室">{{ getRoomName(detailData.classroomId) }}</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="detailData.status === 1 ? 'success' : 'info'" size="small">{{ detailData.status === 1 ? '进行中' : '已结束' }}</el-tag>
</el-descriptions-item>
</el-descriptions>
<template #footer>
<el-button @click="detailVisible = false">关闭</el-button>
</template>
</el-dialog>
</el-dialog>
</template>
<script setup>
import { ref, watch, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Delete } from '@element-plus/icons-vue'
import { getSchedulePage, addSchedule, updateSchedule, deleteSchedule, getTeacherList, getCourses, getRooms, getBuildingList } from '@/api/info'
const props = defineProps({
modelValue: { type: Boolean, default: false },
classData: { type: Object, default: null }
})
const emit = defineEmits(['update:modelValue', 'closed'])
const visible = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
})
const loading = ref(false)
const scheduleList = ref([])
//
const teacherOptions = ref([])
const courseOptions = ref([])
const buildingOptions = ref([])
const allRooms = ref([]) // buildingId
const weekdayOptions = [
{ label: '星期一', value: 1 },
{ label: '星期二', value: 2 },
{ label: '星期三', value: 3 },
{ label: '星期四', value: 4 },
{ label: '星期五', value: 5 },
{ label: '星期六', value: 6 },
{ label: '星期日', value: 7 }
]
const weekLabel = ['', '一', '二', '三', '四', '五', '六', '日']
const getTeacherName = (teacherId) => {
const t = teacherOptions.value.find(item => item.id === teacherId)
return t ? t.name : ''
}
const getCourseName = (courseId) => {
const c = courseOptions.value.find(item => item.id === courseId)
return c ? c.courseName : ''
}
const getRoomName = (roomId) => {
const r = allRooms.value.find(item => item.id === roomId)
return r ? r.name : ''
}
const getBuildingName = (classroomId) => {
const r = allRooms.value.find(item => item.id === classroomId)
if (!r) return ''
const b = buildingOptions.value.find(item => item.id === r.buildingId)
return b ? b.buildingName : ''
}
//
const fetchBaseData = async () => {
try {
const [teacherRes, courseRes, roomRes, buildingRes] = await Promise.all([
getTeacherList(),
getCourses({ current: 1, size: 9999 }),
getRooms({ current: 1, size: 9999 }),
getBuildingList()
])
if (teacherRes.code === 200 && teacherRes.data) {
teacherOptions.value = Array.isArray(teacherRes.data) ? teacherRes.data : []
}
if (courseRes.code === 200 && courseRes.data) {
courseOptions.value = courseRes.data.records || []
}
if (roomRes.code === 200 && roomRes.data) {
const list = roomRes.data.records || []
allRooms.value = list.map(r => ({
id: r.id,
name: r.roomName || r.classroomName || `教室${r.id}`,
buildingId: r.buildingId
}))
}
if (buildingRes.code === 200 && buildingRes.data) {
buildingOptions.value = Array.isArray(buildingRes.data) ? buildingRes.data : []
}
} catch {
//
}
}
//
const filteredRoomOptions = computed(() => {
if (!addForm.value.buildingId) return []
return allRooms.value.filter(r => r.buildingId === addForm.value.buildingId)
})
//
const onBuildingChange = () => {
addForm.value.classroomId = null
}
//
const fetchSchedules = async () => {
if (!props.classData) return
try {
const res = await getSchedulePage({
current: 1,
size: 9999,
classId: props.classData.id
})
if (res.code === 200 && res.data) {
scheduleList.value = res.data.records || []
}
} catch {
scheduleList.value = []
}
}
//
watch(() => props.modelValue, async (val) => {
if (val && props.classData) {
loading.value = true
scheduleList.value = []
try {
await Promise.all([fetchBaseData(), fetchSchedules()])
} catch {
// ignore
} finally {
loading.value = false
}
}
})
// ==================== / ====================
const addVisible = ref(false)
const addLoading = ref(false)
const addFormRef = ref(null)
const isEdit = ref(false)
const editId = ref(null)
const addRules = {
courseId: [{ required: true, message: '请选择课程', trigger: 'change' }],
teacherId: [{ required: true, message: '请选择授课老师', trigger: 'change' }],
weekDay: [{ required: true, message: '请选择星期', trigger: 'change' }],
buildingId: [{ required: true, message: '请选择教学楼', trigger: 'change' }],
classroomId: [{ required: true, message: '请选择教室', trigger: 'change' }],
startSection: [{ required: true, message: '请输入开始节次', trigger: 'blur' }],
endSection: [{ required: true, message: '请输入结束节次', trigger: 'blur' }],
startWeek: [{ required: true, message: '请输入开始周', trigger: 'blur' }],
endWeek: [{ required: true, message: '请输入结束周', trigger: 'blur' }],
firstClassTime: [{ required: true, message: '请选择上课时间', trigger: 'change' }]
}
const defaultForm = () => ({
courseId: null,
teacherId: null,
weekDay: 1,
buildingId: null,
classroomId: null,
startSection: 1,
endSection: 2,
startWeek: 1,
endWeek: 16,
firstClassTime: '',
semester: '',
status: 1
})
const addForm = ref(defaultForm())
const onFormDialogClosed = () => {
isEdit.value = false
editId.value = null
}
const showAddDialog = () => {
addForm.value = defaultForm()
isEdit.value = false
editId.value = null
addVisible.value = true
}
const showEditDialog = (row) => {
// classroomId buildingId
const room = allRooms.value.find(r => r.id === row.classroomId)
addForm.value = {
courseId: row.courseId,
teacherId: row.teacherId,
weekDay: row.weekDay,
buildingId: room ? room.buildingId : null,
classroomId: row.classroomId,
startSection: row.startSection,
endSection: row.endSection,
startWeek: row.startWeek,
endWeek: row.endWeek,
firstClassTime: row.firstClassTime || '',
semester: row.semester || '',
status: row.status
}
isEdit.value = true
editId.value = row.id
addVisible.value = true
}
const saveSchedule = async () => {
if (!addFormRef.value) return
try {
await addFormRef.value.validate()
addLoading.value = true
const payload = {
courseId: addForm.value.courseId,
teacherId: addForm.value.teacherId,
weekDay: addForm.value.weekDay,
buildingId: addForm.value.buildingId,
classroomId: addForm.value.classroomId,
classId: props.classData.id,
startSection: addForm.value.startSection,
endSection: addForm.value.endSection,
startWeek: addForm.value.startWeek,
endWeek: addForm.value.endWeek,
firstClassTime: addForm.value.firstClassTime,
semester: addForm.value.semester,
status: addForm.value.status
}
if (isEdit.value) {
payload.id = editId.value
await updateSchedule(payload)
ElMessage.success('修改成功')
} else {
await addSchedule(payload)
ElMessage.success('新增成功')
}
addVisible.value = false
await fetchSchedules()
} catch {
//
} finally {
addLoading.value = false
}
}
// ==================== ====================
const detailVisible = ref(false)
const detailData = ref(null)
const showDetail = (row) => {
detailData.value = row
detailVisible.value = true
}
// ==================== ====================
const tableRef = ref(null)
const selectedIds = ref([])
const onSelectionChange = (selection) => {
selectedIds.value = selection.map(item => item.id)
}
const handleDelete = (id) => {
ElMessageBox.confirm('确定要删除该课程安排吗?', '提示', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' })
.then(async () => {
await deleteSchedule([id])
ElMessage.success('删除成功')
await fetchSchedules()
})
.catch(() => {})
}
const batchDelete = () => {
if (selectedIds.value.length === 0) return
ElMessageBox.confirm(`确定要删除选中的 ${selectedIds.value.length} 条课程安排吗?`, '提示', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' })
.then(async () => {
await deleteSchedule(selectedIds.value)
ElMessage.success('删除成功')
selectedIds.value = []
await fetchSchedules()
})
.catch(() => {})
}
</script>
<style scoped>
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 0 16px 0;
}
.toolbar-left {
display: flex;
gap: 10px;
}
.toolbar-tip {
font-size: 13px;
color: #909399;
}
</style>

@ -0,0 +1,286 @@
<template>
<div class="page-container fade-in-up">
<div class="page-header">
<h2 class="page-title">课程信息</h2>
<p class="page-subtitle">管理学校课程基本信息</p>
</div>
<div class="filter-bar">
<el-input v-model="searchCourseName" placeholder="搜索课程名称" :prefix-icon="Search" clearable size="default" style="width: 220px" />
<el-select v-model="searchCourseType" placeholder="课程类型" clearable size="default" style="width: 140px">
<el-option v-for="t in courseTypes" :key="t" :label="t" :value="t" />
</el-select>
<el-select v-model="searchTeacherName" placeholder="教师名称" clearable filterable size="default" style="width: 180px">
<el-option v-for="t in teacherOptions" :key="t.id" :label="t.name" :value="t.name" />
</el-select>
<el-button type="primary" :icon="Plus" @click="showDialog()"></el-button>
<el-button :icon="Delete" :disabled="selectedRows.length === 0" @click="batchDelete"></el-button>
</div>
<div class="data-table-card">
<el-table :data="courses" stripe @selection-change="handleSelection" row-key="id">
<el-table-column type="selection" width="45" />
<el-table-column prop="courseCode" label="课程编码" width="140" />
<el-table-column prop="courseName" label="课程名称" min-width="160" />
<el-table-column prop="courseType" label="课程类型" width="100" align="center" />
<el-table-column prop="credit" label="学分" width="80" align="center" />
<el-table-column label="授课老师" width="120">
<template #default="{ row }">{{ getTeacherName(row.teacherId) }}</template>
</el-table-column>
<el-table-column prop="description" label="课程描述" min-width="200" show-overflow-tooltip />
<el-table-column prop="status" label="状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'info'" size="small">{{ row.status === 1 ? '开放中' : '已结束' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="150" align="center" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" :icon="Edit" @click="showDialog(row)"></el-button>
<el-button link type="danger" size="small" :icon="Delete" @click="deleteRow(row)"></el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
v-model:current-page="pageCurrent"
v-model:page-size="pageSize"
:total="total"
:page-sizes="[10, 20, 50]"
size="small"
background
layout="total, sizes, prev, pager, next"
style="justify-content: flex-end; margin-top: 16px"
@current-change="fetchCourses"
@size-change="onSizeChange"
/>
</div>
<!-- 添加/编辑课程弹窗 -->
<el-dialog v-model="dialogVisible" :title="editing ? '编辑课程信息' : '添加课程'" width="560px" destroy-on-close>
<el-form ref="formRef" :model="form" :rules="rules" label-width="90px" label-position="left">
<el-form-item label="课程编码" prop="courseCode">
<el-input v-model="form.courseCode" placeholder="请输入课程编码" />
</el-form-item>
<el-form-item label="课程名称" prop="courseName">
<el-input v-model="form.courseName" placeholder="请输入课程名称" />
</el-form-item>
<el-form-item label="课程类型" prop="courseType">
<el-select v-model="form.courseType" placeholder="请选择课程类型" style="width: 100%">
<el-option v-for="t in courseTypes" :key="t" :label="t" :value="t" />
</el-select>
</el-form-item>
<el-form-item label="学分" prop="credit">
<el-input-number v-model="form.credit" :min="0" :max="20" :step="0.5" placeholder="请输入学分" style="width: 100%" />
</el-form-item>
<el-form-item label="授课老师" prop="teacherId">
<el-select v-model="form.teacherId" placeholder="请选择授课老师" filterable style="width: 100%">
<el-option v-for="t in teacherOptions" :key="t.id" :label="t.name" :value="t.id" />
</el-select>
</el-form-item>
<el-form-item label="课程描述">
<el-input v-model="form.description" type="textarea" :rows="3" placeholder="请输入课程描述" />
</el-form-item>
<el-form-item label="状态">
<el-radio-group v-model="form.status">
<el-radio :value="1">开放中</el-radio>
<el-radio :value="0">已结束</el-radio>
</el-radio-group>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="saveCourse"></el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, onMounted, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Search, Plus, Delete, Edit } from '@element-plus/icons-vue'
import { addCourse, getCourses, updateCourse, deleteCourse } from '@/api/info'
import { getTeacherList } from '@/api/info'
const searchCourseName = ref('')
const searchCourseType = ref('')
const searchTeacherName = ref('')
const pageCurrent = ref(1)
const pageSize = ref(10)
const dialogVisible = ref(false)
const editing = ref(false)
const selectedRows = ref([])
const formRef = ref(null)
const teacherOptions = ref([])
const courseTypes = ['必修课', '选修课']
const rules = {
courseCode: [{ required: true, message: '请输入课程编码', trigger: 'blur' }],
courseName: [{ required: true, message: '请输入课程名称', trigger: 'blur' }],
courseType: [{ required: true, message: '请选择课程类型', trigger: 'change' }],
credit: [{ required: true, message: '请输入学分', trigger: 'blur' }],
teacherId: [{ required: true, message: '请选择授课老师', trigger: 'change' }]
}
const form = ref({
id: '',
courseCode: '',
courseName: '',
courseType: '必修课',
credit: 0,
teacherId: null,
description: '',
status: 1
})
const courses = ref([])
const total = ref(0)
// teacherId
const getTeacherName = (teacherId) => {
const teacher = teacherOptions.value.find(t => t.id === teacherId)
return teacher ? teacher.name : ''
}
const handleSelection = (rows) => { selectedRows.value = rows }
const showDialog = (row) => {
if (row) {
editing.value = true
form.value = {
id: row.id,
courseCode: row.courseCode,
courseName: row.courseName,
courseType: row.courseType,
credit: row.credit,
teacherId: row.teacherId,
description: row.description,
status: row.status
}
} else {
editing.value = false
form.value = { id: '', courseCode: '', courseName: '', courseType: '必修课', credit: 0, teacherId: null, description: '', status: 1 }
}
dialogVisible.value = true
}
const saveCourse = async () => {
if (!formRef.value) return
try {
await formRef.value.validate()
if (editing.value) {
await updateCourse({
id: form.value.id,
courseCode: form.value.courseCode,
courseName: form.value.courseName,
courseType: form.value.courseType,
credit: form.value.credit,
teacherId: form.value.teacherId,
description: form.value.description,
status: form.value.status
})
ElMessage.success('编辑成功')
} else {
await addCourse({
courseCode: form.value.courseCode,
courseName: form.value.courseName,
courseType: form.value.courseType,
credit: form.value.credit,
teacherId: form.value.teacherId,
description: form.value.description,
status: form.value.status
})
ElMessage.success('添加成功')
}
dialogVisible.value = false
fetchCourses()
} catch {
//
}
}
const deleteRow = (row) => {
ElMessageBox.confirm(`确认删除课程 "${row.courseName}"`, '提示', { type: 'warning' })
.then(async () => {
await deleteCourse([row.id])
ElMessage.success('删除成功')
fetchCourses()
})
.catch(() => {})
}
const batchDelete = () => {
ElMessageBox.confirm(`确认删除选中的 ${selectedRows.value.length} 门课程?`, '批量删除', { type: 'warning' })
.then(async () => {
const ids = selectedRows.value.map(r => r.id)
await deleteCourse(ids)
ElMessage.success('批量删除成功')
fetchCourses()
})
.catch(() => {})
}
//
const fetchTeachers = async () => {
try {
const res = await getTeacherList()
if (res.code === 200) {
teacherOptions.value = res.data || []
}
} catch {
//
}
}
//
const fetchCourses = async () => {
try {
const res = await getCourses({
current: pageCurrent.value,
size: pageSize.value,
courseName: searchCourseName.value || undefined,
teacherName: searchTeacherName.value || undefined,
courseType: searchCourseType.value || undefined
})
if (res.code === 200) {
courses.value = res.data.records || []
total.value = res.data.total || 0
}
} catch {
ElMessage.error('获取课程列表失败')
}
}
const onSizeChange = () => {
pageCurrent.value = 1
fetchCourses()
}
watch(searchCourseName, () => {
pageCurrent.value = 1
fetchCourses()
})
watch(searchCourseType, () => {
pageCurrent.value = 1
fetchCourses()
})
watch(searchTeacherName, () => {
pageCurrent.value = 1
fetchCourses()
})
onMounted(() => {
fetchTeachers()
fetchCourses()
})
</script>
<style lang="scss" scoped>
.data-table-card {
background: #ffffff;
border-radius: 8px;
padding: 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
</style>

@ -0,0 +1,443 @@
<template>
<div class="page-container fade-in-up">
<div class="page-header">
<h2 class="page-title">学生信息</h2>
<p class="page-subtitle">管理学生信息支持新增与编辑</p>
</div>
<div class="filter-bar">
<el-input v-model="searchKey" placeholder="搜索姓名/学号..." :prefix-icon="Search" clearable size="default" style="width: 220px" />
<el-select v-model="classFilter" placeholder="班级筛选" clearable size="default" style="width: 180px">
<el-option v-for="c in classList" :key="c.id" :label="c.className" :value="c.id" />
</el-select>
<!-- <el-upload action="#" :show-file-list="false" accept=".xlsx,.xls">
<el-button :icon="Upload">导入Excel</el-button>
</el-upload> -->
<el-button type="primary" :icon="Plus" @click="showAddDialog"></el-button>
<el-button :icon="Delete" :disabled="!hasSelected" @click="batchDelete"></el-button>
</div>
<div class="data-table-card">
<el-table :data="students" stripe v-loading="loading" @selection-change="handleSelectionChange">
<el-table-column type="selection" width="45" />
<el-table-column label="头像" width="80" align="center">
<template #default="{ row }">
<el-avatar :src="row.faceImage ? fileHttp + '/' + row.faceImage : ''" :size="40">
{{ row.name?.charAt(0) }}
</el-avatar>
</template>
</el-table-column>
<el-table-column prop="studentNo" label="学号" width="120" />
<el-table-column prop="name" label="姓名" width="100" />
<el-table-column prop="gender" label="性别" width="70" align="center">
<template #default="{ row }">
{{ row.gender === 1 ? '男' : '女' }}
</template>
</el-table-column>
<el-table-column prop="className" label="班级" min-width="160" />
<el-table-column prop="phone" label="手机号" width="140" />
<el-table-column prop="status" label="状态" width="80" align="center">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'info'" size="small">
{{ row.status === 1 ? '在读' : '离校' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="160" align="center" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" :icon="Edit" @click="editStudent(row)"></el-button>
<el-button link type="danger" size="small" :icon="Delete" @click="deleteOne(row)"></el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
v-model:current-page="pageCurrent"
:total="total"
:page-size="pageSize"
size="small"
background
layout="total, prev, pager, next"
style="justify-content: flex-end; margin-top: 16px"
/>
</div>
<!-- 添加/编辑弹窗 -->
<el-dialog v-model="dialogVisible" :title="editing ? '编辑学生信息' : '添加学生'" width="640px" destroy-on-close top="3vh">
<div class="dialog-scroll-body">
<div v-if="editing" class="edit-avatar">
<el-avatar :src="form.faceImage ? fileHttp + '/' + form.faceImage : ''" :size="72">
{{ form.name?.charAt(0) }}
</el-avatar>
</div>
<el-form ref="formRef" :model="form" :rules="formRules" label-width="90px" label-position="left">
<el-form-item label="学号" prop="studentNo">
<el-input v-model="form.studentNo" placeholder="请输入学号" />
</el-form-item>
<el-form-item label="姓名" prop="name">
<el-input v-model="form.name" placeholder="请输入姓名" />
</el-form-item>
<el-form-item label="性别">
<el-radio-group v-model="form.gender">
<el-radio :value="1"></el-radio>
<el-radio :value="0"></el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="班级" prop="classId">
<el-select v-model="form.classId" placeholder="请选择班级" style="width: 100%">
<el-option v-for="c in classList" :key="c.id" :label="c.className" :value="c.id" />
</el-select>
</el-form-item>
<el-form-item label="手机号" prop="phone">
<el-input v-model="form.phone" placeholder="请输入手机号" maxlength="11" />
</el-form-item>
<el-form-item label="状态">
<el-radio-group v-model="form.status">
<el-radio :value="1">在读</el-radio>
<el-radio :value="0">离校</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item v-if="!editing" label="正脸照片" required>
<el-upload
v-model:file-list="frontImages"
list-type="picture-card"
:auto-upload="false"
accept="image/*"
multiple
:on-preview="handlePreview"
>
<el-icon><Plus /></el-icon>
</el-upload>
</el-form-item>
<el-form-item v-if="!editing" label="左脸照片" required>
<el-upload
v-model:file-list="leftImages"
list-type="picture-card"
:auto-upload="false"
accept="image/*"
multiple
:on-preview="handlePreview"
>
<el-icon><Plus /></el-icon>
</el-upload>
</el-form-item>
<el-form-item v-if="!editing" label="右脸照片" required>
<el-upload
v-model:file-list="rightImages"
list-type="picture-card"
:auto-upload="false"
accept="image/*"
multiple
:on-preview="handlePreview"
>
<el-icon><Plus /></el-icon>
</el-upload>
</el-form-item>
<div v-if="!editing" class="upload-notice">
<el-alert
type="warning"
:closable="false"
show-icon
title="请谨慎选择照片,提交后不支持在线修改已上传的图片"
/>
</div>
</el-form>
</div>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="saveStudent"></el-button>
</template>
</el-dialog>
<!-- 图片预览弹窗 -->
<el-dialog v-model="previewVisible" title="图片预览" width="600px" destroy-on-close>
<div class="preview-body">
<el-image :src="previewUrl" fit="contain" style="width: 100%; max-height: 70vh" />
</div>
</el-dialog>
</div>
</template>
<script setup>
import { ref, computed, watch, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Search, Plus, Edit, Delete } from '@element-plus/icons-vue'
import fileHttp from '@/utils/fileHttp'
import { getStudentPage, getClassList, addStudent, updateStudent, deleteStudent } from '@/api/info'
const searchKey = ref('')
const classFilter = ref('')
const pageCurrent = ref(1)
const pageSize = ref(10)
const total = ref(0)
const loading = ref(false)
const submitting = ref(false)
const dialogVisible = ref(false)
const editing = ref(false)
const selectedRows = ref([])
const hasSelected = ref(false)
const classList = ref([])
//
const frontImages = ref([])
const leftImages = ref([])
const rightImages = ref([])
const formRef = ref(null)
//
const previewVisible = ref(false)
const previewUrl = ref('')
const handlePreview = (file) => {
previewUrl.value = file.url || URL.createObjectURL(file.raw)
previewVisible.value = true
}
const formRules = {
studentNo: [
{ required: true, message: '请输入学号', trigger: 'blur' }
],
name: [
{ required: true, message: '请输入姓名', trigger: 'blur' }
],
classId: [
{ required: true, message: '请选择班级', trigger: 'change' }
],
phone: [
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号', trigger: 'blur' }
]
}
const form = ref({
id: null,
studentNo: '',
name: '',
gender: 1,
classId: '',
phone: '',
status: 1
})
// classId className
const classMap = computed(() => {
const map = {}
classList.value.forEach(c => { map[c.id] = c.className })
return map
})
const students = ref([])
//
const fetchClassList = async () => {
try {
const res = await getClassList()
if (res?.data) {
classList.value = res.data
}
} catch { /* 班级接口不可用时降级 */ }
}
//
const fetchStudents = async () => {
loading.value = true
try {
const params = {
current: pageCurrent.value,
size: pageSize.value
}
if (searchKey.value) params.keyword = searchKey.value
if (classFilter.value) params.classId = classFilter.value
const res = await getStudentPage(params)
if (res?.code === 200 && res.data) {
const records = (res.data.records || []).map(item => ({
...item,
className: classMap.value[item.classId] || `班级${item.classId}`
}))
students.value = records
total.value = res.data.total || students.value.length
}
} finally {
loading.value = false
}
}
//
let searchTimer = null
watch(searchKey, () => {
clearTimeout(searchTimer)
searchTimer = setTimeout(() => {
pageCurrent.value = 1
fetchStudents()
}, 400)
})
watch(classFilter, () => {
pageCurrent.value = 1
fetchStudents()
})
watch(pageCurrent, () => fetchStudents())
onMounted(async () => {
await fetchClassList()
fetchStudents()
})
const handleSelectionChange = (rows) => {
selectedRows.value = rows
hasSelected.value = rows.length > 0
}
const showAddDialog = () => {
editing.value = false
form.value = { id: null, studentNo: '', name: '', gender: 1, classId: '', phone: '', status: 1 }
frontImages.value = []
leftImages.value = []
rightImages.value = []
dialogVisible.value = true
formRef.value?.clearValidate()
}
const editStudent = (row) => {
editing.value = true
form.value = {
id: row.id,
studentNo: row.studentNo,
name: row.name,
gender: row.gender,
classId: row.classId,
phone: row.phone || '',
status: row.status,
faceImage: row.faceImage || ''
}
frontImages.value = []
leftImages.value = []
rightImages.value = []
dialogVisible.value = true
formRef.value?.clearValidate()
}
const saveStudent = async () => {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
//
if (!editing.value && frontImages.value.length + leftImages.value.length + rightImages.value.length === 0) {
ElMessage.warning('请至少上传一张学生照片')
return
}
submitting.value = true
try {
if (editing.value) {
const payload = {
id: form.value.id,
studentNo: form.value.studentNo,
name: form.value.name,
gender: form.value.gender,
classId: form.value.classId,
phone: form.value.phone,
status: form.value.status
}
await updateStudent(payload)
} else {
const fd = new FormData()
fd.append('studentNo', form.value.studentNo)
fd.append('name', form.value.name)
fd.append('gender', form.value.gender)
fd.append('classId', form.value.classId || '')
fd.append('phone', form.value.phone || '')
fd.append('status', form.value.status)
frontImages.value.forEach(f => fd.append('frontImage', f.raw))
leftImages.value.forEach(f => fd.append('leftImage', f.raw))
rightImages.value.forEach(f => fd.append('rightImage', f.raw))
await addStudent(fd)
}
ElMessage.success(editing.value ? '编辑成功' : '添加成功')
dialogVisible.value = false
fetchStudents()
} catch {
ElMessage.error(editing.value ? '编辑失败' : '添加失败')
} finally {
submitting.value = false
}
}
const deleteOne = (row) => {
ElMessageBox.confirm(`确认删除学生 ${row.name}`, '提示', { type: 'warning' })
.then(async () => {
try {
await deleteStudent([row.id])
ElMessage.success('删除成功')
fetchStudents()
} catch {
ElMessage.error('删除失败')
}
})
.catch(() => {})
}
const batchDelete = () => {
if (selectedRows.value.length === 0) return
ElMessageBox.confirm(`确认删除选中的 ${selectedRows.value.length} 名学生?`, '批量删除', { type: 'warning' })
.then(async () => {
try {
const ids = selectedRows.value.map(r => r.id)
await deleteStudent(ids)
ElMessage.success('批量删除成功')
selectedRows.value = []
hasSelected.value = false
fetchStudents()
} catch {
ElMessage.error('批量删除失败')
}
})
.catch(() => {})
}
</script>
<style lang="scss" scoped>
.data-table-card {
background: #ffffff;
border-radius: 8px;
padding: 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
:deep(.el-avatar) {
font-size: 18px;
}
}
.edit-avatar {
display: flex;
justify-content: center;
margin-bottom: 16px;
:deep(.el-avatar) {
font-size: 30px;
}
}
.dialog-scroll-body {
max-height: calc(85vh - 160px);
overflow-y: auto;
padding-right: 4px;
:deep(.el-upload--picture-card) {
width: 90px;
height: 90px;
line-height: 90px;
}
:deep(.el-upload-list--picture-card .el-upload-list__item) {
width: 90px;
height: 90px;
}
}
.upload-notice {
margin-top: -8px;
margin-bottom: 12px;
}
</style>

@ -0,0 +1,317 @@
<template>
<div class="page-container fade-in-up">
<div class="page-header">
<h2 class="page-title">教师信息</h2>
<p class="page-subtitle">管理学校教师基本信息与任教课程</p>
</div>
<div class="filter-bar">
<el-input v-model="searchKey" placeholder="搜索姓名/工号/院系" :prefix-icon="Search" clearable size="default" style="width: 220px" />
<el-select v-model="titleFilter" placeholder="职称筛选" clearable size="default" style="width: 160px">
<el-option v-for="t in titles" :key="t" :label="t" :value="t" />
</el-select>
<el-button type="primary" :icon="Plus" @click="showDialog()"></el-button>
<el-button :icon="Delete" :disabled="selectedRows.length === 0" @click="batchDelete"></el-button>
</div>
<div class="data-table-card">
<el-table :data="teachers" stripe @selection-change="handleSelection" row-key="id">
<el-table-column type="selection" width="45" />
<el-table-column prop="teacherNo" label="工号" width="120" />
<el-table-column prop="name" label="姓名" width="100" />
<el-table-column prop="gender" label="性别" width="70" align="center">
<template #default="{ row }">{{ row.gender === 1 ? '男' : '女' }}</template>
</el-table-column>
<el-table-column prop="department" label="所属院系" min-width="160" />
<el-table-column prop="title" label="职称" width="120" />
<el-table-column prop="phone" label="手机号" width="140" />
<el-table-column prop="email" label="邮箱" min-width="180" />
<el-table-column prop="status" label="状态" width="90" align="center">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'info'" size="small">{{ row.status === 1 ? '在职' : '离职' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="200" align="center" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" :icon="Edit" @click="showDialog(row)"></el-button>
<el-button link type="info" size="small" :icon="View" @click="showDetail(row)"></el-button>
<el-button link type="danger" size="small" :icon="Delete" @click="deleteRow(row)"></el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
v-model:current-page="pageCurrent"
v-model:page-size="pageSize"
:total="total"
:page-sizes="[10, 20, 50]"
size="small"
background
layout="total, sizes, prev, pager, next"
style="justify-content: flex-end; margin-top: 16px"
@current-change="fetchTeachers"
@size-change="onSizeChange"
/>
</div>
<!-- 添加/编辑教师弹窗 -->
<el-dialog v-model="dialogVisible" :title="editing ? '编辑教师信息' : '添加教师'" width="560px" destroy-on-close>
<el-form ref="formRef" :model="form" :rules="rules" label-width="90px" label-position="left">
<el-form-item label="工号" prop="teacherNo">
<el-input v-model="form.teacherNo" placeholder="请输入工号" />
</el-form-item>
<el-form-item label="姓名" prop="name">
<el-input v-model="form.name" placeholder="请输入姓名" />
</el-form-item>
<el-form-item label="性别">
<el-radio-group v-model="form.gender">
<el-radio :value="1"></el-radio>
<el-radio :value="0"></el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="所属院系" prop="department">
<el-input v-model="form.department" placeholder="请输入所属院系" />
</el-form-item>
<el-form-item label="职称" prop="title">
<el-select v-model="form.title" placeholder="请选择职称" style="width: 100%">
<el-option v-for="t in titles" :key="t" :label="t" :value="t" />
</el-select>
</el-form-item>
<el-form-item label="手机号" prop="phone">
<el-input v-model="form.phone" placeholder="请输入手机号" />
</el-form-item>
<el-form-item label="邮箱" prop="email">
<el-input v-model="form.email" placeholder="请输入邮箱" />
</el-form-item>
<el-form-item label="状态">
<el-radio-group v-model="form.status">
<el-radio :value="1">在职</el-radio>
<el-radio :value="0">离职</el-radio>
</el-radio-group>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="saveTeacher"></el-button>
</template>
</el-dialog>
<!-- 教师详情弹窗 -->
<el-dialog v-model="detailVisible" title="教师详情" width="600px" destroy-on-close>
<div class="detail-section" v-loading="detailLoading" v-if="detailTeacher">
<div class="detail-avatar">
<el-icon :size="48"><UserFilled /></el-icon>
</div>
<el-descriptions :column="2" border>
<el-descriptions-item label="工号">{{ detailTeacher.teacherNo }}</el-descriptions-item>
<el-descriptions-item label="姓名">{{ detailTeacher.name }}</el-descriptions-item>
<el-descriptions-item label="性别">{{ detailTeacher.gender === 1 ? '男' : '女' }}</el-descriptions-item>
<el-descriptions-item label="院系">{{ detailTeacher.department }}</el-descriptions-item>
<el-descriptions-item label="职称">{{ detailTeacher.title }}</el-descriptions-item>
<el-descriptions-item label="手机号">{{ detailTeacher.phone }}</el-descriptions-item>
<el-descriptions-item label="邮箱">{{ detailTeacher.email }}</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="detailTeacher.status === 1 ? 'success' : 'info'" size="small">{{ detailTeacher.status === 1 ? '在职' : '离职' }}</el-tag>
</el-descriptions-item>
</el-descriptions>
</div>
</el-dialog>
</div>
</template>
<script setup>
import { ref, onMounted, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Search, Plus, Delete, Edit, View } from '@element-plus/icons-vue'
import { addTeacher, getTeachers, updateTeacher, getTeacherDetail, deleteTeacher } from '@/api/info'
const searchKey = ref('')
const titleFilter = ref('')
const pageCurrent = ref(1)
const pageSize = ref(10)
const dialogVisible = ref(false)
const detailVisible = ref(false)
const editing = ref(false)
const selectedRows = ref([])
const detailTeacher = ref(null)
const detailLoading = ref(false)
const formRef = ref(null)
const rules = {
teacherNo: [{ required: true, message: '请输入工号', trigger: 'blur' }],
name: [{ required: true, message: '请输入姓名', trigger: 'blur' }],
department: [{ required: true, message: '请输入所属院系', trigger: 'blur' }],
title: [{ required: true, message: '请选择职称', trigger: 'change' }],
phone: [
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号', trigger: 'blur' }
],
email: [
{ type: 'email', message: '请输入正确的邮箱地址', trigger: 'blur' }
]
}
const titles = ['教授', '副教授', '讲师', '助教', '研究员']
const form = ref({
id: '',
teacherNo: '',
name: '',
gender: 1,
department: '',
title: '讲师',
phone: '',
email: '',
status: 1
})
const teachers = ref([])
const total = ref(0)
const handleSelection = (rows) => { selectedRows.value = rows }
const showDialog = (row) => {
if (row) {
editing.value = true
form.value = { ...row }
} else {
editing.value = false
form.value = { id: '', teacherNo: '', name: '', gender: 1, department: '', title: '讲师', phone: '', email: '', status: 1 }
}
dialogVisible.value = true
}
const saveTeacher = async () => {
if (!formRef.value) return
try {
await formRef.value.validate()
if (editing.value) {
await updateTeacher({
id: form.value.id,
teacherNo: form.value.teacherNo,
name: form.value.name,
gender: form.value.gender,
title: form.value.title,
department: form.value.department,
phone: form.value.phone,
email: form.value.email,
status: form.value.status
})
ElMessage.success('编辑成功')
fetchTeachers()
} else {
await addTeacher({
teacherNo: form.value.teacherNo,
name: form.value.name,
gender: form.value.gender,
title: form.value.title,
department: form.value.department,
phone: form.value.phone,
email: form.value.email,
status: form.value.status
})
ElMessage.success('添加成功')
fetchTeachers()
}
dialogVisible.value = false
} catch {
}
}
const showDetail = async (row) => {
detailVisible.value = true
detailLoading.value = true
try {
const res = await getTeacherDetail(row.id)
if (res.code === 200) {
detailTeacher.value = res.data
}
} catch {
ElMessage.error('获取教师详情失败')
detailVisible.value = false
} finally {
detailLoading.value = false
}
}
const deleteRow = (row) => {
ElMessageBox.confirm(`确认删除教师 "${row.name}"`, '提示', { type: 'warning' })
.then(async () => {
await deleteTeacher([row.id])
ElMessage.success('删除成功')
fetchTeachers()
})
.catch(() => {})
}
const batchDelete = () => {
ElMessageBox.confirm(`确认删除选中的 ${selectedRows.value.length} 名教师?`, '批量删除', { type: 'warning' })
.then(async () => {
const ids = selectedRows.value.map(r => r.id)
await deleteTeacher(ids)
ElMessage.success('批量删除成功')
fetchTeachers()
})
.catch(() => {})
}
//
const fetchTeachers = async () => {
try {
const res = await getTeachers({
current: pageCurrent.value,
size: pageSize.value,
keyword: searchKey.value,
title: titleFilter.value
})
if (res.code === 200) {
teachers.value = res.data.records || []
total.value = res.data.total || 0
}
} catch {
ElMessage.error('获取教师列表失败')
}
}
const onSizeChange = () => {
pageCurrent.value = 1
fetchTeachers()
}
//
watch(searchKey, () => {
pageCurrent.value = 1
fetchTeachers()
})
//
watch(titleFilter, () => {
pageCurrent.value = 1
fetchTeachers()
})
onMounted(() => {
fetchTeachers()
})
</script>
<style lang="scss" scoped>
.data-table-card {
background: #ffffff;
border-radius: 8px;
padding: 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
.detail-section {
.detail-avatar {
display: flex;
justify-content: center;
margin-bottom: 20px;
color: #52c41a;
}
}
</style>

@ -0,0 +1,304 @@
<template>
<div class="login-page">
<!-- 简洁装饰 -->
<div class="deco">
<div class="deco-circle deco-circle-1"></div>
<div class="deco-circle deco-circle-2"></div>
<div class="deco-circle deco-circle-3"></div>
</div>
<!-- 居中悬浮登录卡片 -->
<div class="login-card-wrapper">
<div class="login-card">
<!-- 品牌 Logo -->
<div class="login-brand">
<div class="brand-icon">
<el-icon :size="30"><Camera /></el-icon>
</div>
<h1 class="brand-title">教室智能人脸考勤系统</h1>
<p class="brand-desc">Classroom Smart Attendance</p>
</div>
<!-- 登录表单 -->
<el-form :model="form" :rules="rules" ref="formRef" class="login-form">
<el-form-item prop="username">
<el-input
v-model="form.username"
placeholder="工号 / 学号"
:prefix-icon="User"
size="large"
/>
</el-form-item>
<el-form-item prop="password">
<el-input
v-model="form.password"
type="password"
placeholder="请输入密码"
:prefix-icon="Lock"
size="large"
show-password
@keyup.enter="handleLogin"
/>
</el-form-item>
<div class="form-options">
<el-checkbox v-model="rememberMe"></el-checkbox>
<el-link type="primary" underline="never">忘记密码</el-link>
</div>
<el-form-item>
<el-button
type="primary"
size="large"
:loading="loading"
class="login-btn"
@click="handleLogin"
>
{{ loading ? '登录中...' : '进入系统' }}
</el-button>
</el-form-item>
</el-form>
<!-- 版权 -->
<p class="copyright">© 2026 SmartCampus · v1.0</p>
</div>
</div>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { User, Lock } from '@element-plus/icons-vue'
import { login } from '@/api/auth'
import { useUserStore } from '@/stores/user'
const router = useRouter()
const userStore = useUserStore()
const formRef = ref(null)
const loading = ref(false)
const rememberMe = ref(false)
const form = reactive({
username: '',
password: ''
})
const rules = {
username: [{ required: true, message: '请输入工号/学号', trigger: 'blur' }],
password: [{ required: true, message: '请输入密码', trigger: 'blur' }]
}
//
onMounted(() => {
const saved = localStorage.getItem('rememberedLogin')
if (saved) {
try {
const { username, password } = JSON.parse(saved)
form.username = username
form.password = password
rememberMe.value = true
} catch { /* ignore */ }
}
})
const handleLogin = () => {
formRef.value?.validate(async (valid) => {
if (!valid) return
loading.value = true
try {
const res = await login({
username: form.username,
password: form.password
})
userStore.setLoginData(res.data)
//
if (rememberMe.value) {
localStorage.setItem('rememberedLogin', JSON.stringify({
username: form.username,
password: form.password
}))
} else {
localStorage.removeItem('rememberedLogin')
}
ElMessage.success(`欢迎回来,${res.data.realName || res.data.username}`)
router.push('/dashboard')
} catch {
} finally {
loading.value = false
}
})
}
</script>
<style lang="scss" scoped>
// ========== ==========
.login-page {
position: relative;
width: 100%;
height: 100vh;
overflow: hidden;
font-family: $font-family;
background: linear-gradient(160deg, #e8f9e0 0%, #f0f7f0 35%, #f5f7fa 65%, #e8f4ff 100%);
}
// ========== ==========
.deco {
position: absolute;
inset: 0;
pointer-events: none;
z-index: 1;
}
.deco-circle {
position: absolute;
border-radius: 50%;
opacity: 0.07;
}
.deco-circle-1 {
width: 500px;
height: 500px;
background: #52c41a;
top: -120px;
right: -80px;
}
.deco-circle-2 {
width: 350px;
height: 350px;
background: #1890ff;
bottom: -60px;
left: -40px;
}
.deco-circle-3 {
width: 220px;
height: 220px;
background: #52c41a;
top: 45%;
left: 5%;
}
// ========== ==========
.login-card-wrapper {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
pointer-events: none;
}
.login-card {
pointer-events: auto;
width: 420px;
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
border-radius: 20px;
padding: 44px 40px 36px;
box-shadow:
0 8px 40px rgba(0, 0, 0, 0.06),
0 2px 8px rgba(0, 0, 0, 0.03),
inset 0 0 0 1px rgba(255, 255, 255, 0.5);
animation: cardIn 0.6s ease-out;
}
@keyframes cardIn {
from { opacity: 0; transform: translateY(20px) scale(0.96); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
// --- ---
.login-brand {
text-align: center;
margin-bottom: 34px;
}
.brand-icon {
width: 64px; height: 64px;
margin: 0 auto 18px;
background: linear-gradient(135deg, #e8f9e0, #b7eb8f);
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 14px rgba(82, 196, 26, 0.15);
}
.brand-title {
font-size: 19px;
font-weight: 700;
color: $text-primary;
margin: 0 0 6px;
letter-spacing: 2px;
}
.brand-desc {
font-size: 12px;
color: $text-placeholder;
margin: 0;
letter-spacing: 1px;
}
// --- ---
.login-form {
:deep(.el-input__wrapper) {
border-radius: 10px;
box-shadow: 0 0 0 1px #e8e8e8 inset;
transition: all $transition-normal;
&:hover {
box-shadow: 0 0 0 1px $color-primary inset;
}
}
:deep(.el-input.is-focus .el-input__wrapper) {
box-shadow: 0 0 0 1px $color-primary inset, 0 0 0 3px rgba(82, 196, 26, 0.1);
}
}
.form-options {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 18px;
font-size: $font-caption;
}
.login-btn {
width: 100%;
height: 46px;
font-size: 16px;
border-radius: 10px;
letter-spacing: 4px;
background: linear-gradient(135deg, $color-primary, #49b018);
border: none;
transition: all $transition-normal;
&:hover {
background: linear-gradient(135deg, #49b018, #3d9912);
box-shadow: 0 4px 16px rgba(82, 196, 26, 0.3);
}
}
.copyright {
margin-top: 26px;
text-align: center; font-size: 11px;
color: $text-disabled;
}
// ========== ==========
@media (max-width: 768px) {
.login-card {
width: 90vw;
max-width: 380px;
padding: 36px 28px 28px;
}
}
</style>

@ -0,0 +1,289 @@
<template>
<div class="page-container fade-in-up">
<div class="page-header">
<h2 class="page-title">权限管理</h2>
<p class="page-subtitle">管理各角色访问权限保障系统安全</p>
</div>
<!-- 角色列表 -->
<div class="roles-cards">
<div
v-for="role in roles"
:key="role.id"
class="role-card"
:class="{ active: activeRole === role.id }"
@click="activeRole = role.id"
>
<div class="role-icon" :style="{ background: role.color }">
<el-icon :size="20">
<component :is="role.icon" />
</el-icon>
</div>
<div class="role-info">
<div class="role-name">{{ role.name }}</div>
<div class="role-count">{{ role.userCount }} </div>
</div>
</div>
</div>
<!-- 权限矩阵 -->
<div class="perm-matrix-card">
<div class="matrix-header">
<h3>权限分配矩阵</h3>
<el-tag type="success" size="small" effect="plain">当前角色: {{ currentRoleName }}</el-tag>
</div>
<div class="matrix-table">
<table>
<thead>
<tr>
<th class="perm-label">功能模块</th>
<th v-for="perm in permissions" :key="perm.key" class="perm-col">{{ perm.label }}</th>
</tr>
</thead>
<tbody>
<tr v-for="module in modules" :key="module.key">
<td class="perm-label">{{ module.label }}</td>
<td v-for="perm in permissions" :key="perm.key" class="perm-col">
<el-checkbox
v-model="matrix[activeRole][module.key][perm.key]"
:disabled="perm.key === 'view'"
@change="handlePermChange"
/>
</td>
</tr>
</tbody>
</table>
</div>
<!-- 权限说明 -->
<div class="perm-legend">
<div class="legend-item">
<el-icon :size="14" color="#bfbfbf"><InfoFilled /></el-icon>
<span>查看权限为基本权限默认勾选取消需谨慎</span>
</div>
</div>
</div>
<!-- 操作按钮 -->
<div class="save-bar">
<el-button type="primary" size="large" :icon="Check" @click="savePermissions"></el-button>
<el-button size="large" @click="resetPermissions"></el-button>
</div>
</div>
</template>
<script setup>
import { ref, computed, reactive } from 'vue'
import { ElMessage } from 'element-plus'
import { Check } from '@element-plus/icons-vue'
const activeRole = ref('admin')
const roles = ref([
{ id: 'admin', name: '管理员', icon: 'UserFilled', color: 'linear-gradient(135deg, #52c41a, #73d13d)', userCount: 3 },
// { id: 'staff', name: '', icon: 'Avatar', color: 'linear-gradient(135deg, #1890ff, #40a9ff)', userCount: 8 },
{ id: 'teacher', name: '教师', icon: 'User', color: 'linear-gradient(135deg, #722ed1, #9254de)', userCount: 25 }
])
const modules = ref([
{ key: 'dashboard', label: '首页' },
{ key: 'behavior', label: '课堂行为分析' },
{ key: 'history', label: '历史记录查询' },
{ key: 'bigscreen', label: '数据展示大屏' },
{ key: 'settings', label: '系统设置' }
])
const permissions = ref([
{ key: 'view', label: '查看' },
{ key: 'edit', label: '编辑' },
{ key: 'export', label: '导出' },
{ key: 'delete', label: '删除' }
])
//
const matrix = reactive({
admin: {
dashboard: { view: true, edit: true, export: true, delete: true },
behavior: { view: true, edit: true, export: true, delete: true },
history: { view: true, edit: true, export: true, delete: true },
bigscreen: { view: true, edit: true, export: true, delete: true },
settings: { view: true, edit: true, export: true, delete: true }
},
staff: {
dashboard: { view: true, edit: false, export: true, delete: false },
behavior: { view: false, edit: false, export: false, delete: false },
history: { view: true, edit: false, export: true, delete: false },
bigscreen: { view: false, edit: false, export: false, delete: false },
settings: { view: false, edit: false, export: false, delete: false }
},
teacher: {
dashboard: { view: true, edit: false, export: true, delete: false },
behavior: { view: true, edit: false, export: false, delete: false },
history: { view: true, edit: false, export: true, delete: false },
bigscreen: { view: false, edit: false, export: false, delete: false },
settings: { view: false, edit: false, export: false, delete: false }
}
})
const currentRoleName = computed(() => {
return roles.value.find(r => r.id === activeRole.value)?.name || ''
})
const handlePermChange = () => {
// view
}
const savePermissions = () => {
ElMessage.success('权限配置保存成功,角色权限已立即更新')
}
const resetPermissions = () => {
ElMessage.info('已恢复默认权限配置')
}
</script>
<style lang="scss" scoped>
.roles-cards {
display: flex;
gap: 16px;
margin-bottom: 20px;
}
.role-card {
flex: 1;
background: #ffffff;
border-radius: 8px;
padding: 20px;
display: flex;
align-items: center;
gap: 16px;
cursor: pointer;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
border: 2px solid transparent;
transition: all 0.3s ease;
&:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
&.active {
border-color: #52c41a;
box-shadow: 0 4px 16px rgba(82, 196, 26, 0.15);
}
}
.role-icon {
width: 48px;
height: 48px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
color: #ffffff;
flex-shrink: 0;
}
.role-info {
.role-name {
font-size: 16px;
font-weight: 600;
color: #262626;
margin-bottom: 4px;
}
.role-count {
font-size: 12px;
color: #bfbfbf;
}
}
.perm-matrix-card {
background: #ffffff;
border-radius: 8px;
padding: 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
margin-bottom: 20px;
}
.matrix-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
h3 {
font-size: 16px;
font-weight: 600;
color: #262626;
}
}
.matrix-table {
overflow-x: auto;
table {
width: 100%;
border-collapse: collapse;
}
thead th {
padding: 12px 16px;
text-align: center;
font-size: 13px;
font-weight: 600;
color: #262626;
border-bottom: 2px solid #f0f0f0;
background: #fafafa;
}
tbody td {
padding: 14px 16px;
text-align: center;
border-bottom: 1px solid #f5f5f5;
transition: background 0.15s ease;
}
tbody tr:hover td {
background: #fafafa;
}
}
.perm-label {
text-align: left !important;
font-size: 14px;
color: #525252;
font-weight: 500;
min-width: 140px;
}
.perm-col {
min-width: 80px;
}
.perm-legend {
margin-top: 16px;
padding: 10px 16px;
background: #f5f7fa;
border-radius: 6px;
.legend-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: #8c8c8c;
}
}
.save-bar {
display: flex;
gap: 12px;
justify-content: flex-end;
.el-button {
border-radius: 8px;
min-width: 130px;
}
}
</style>

@ -0,0 +1,223 @@
<template>
<div class="page-container fade-in-up">
<div class="page-header">
<h2 class="page-title">考勤规则设置</h2>
<p class="page-subtitle">自定义各课程考勤规则与异常预警参数</p>
</div>
<div class="rules-grid">
<!-- 缺勤阈值设置 -->
<div class="rule-card">
<div class="rule-card-header">
<el-icon :size="18" color="#52c41a"><Warning /></el-icon>
<h4>缺勤阈值设置</h4>
</div>
<el-form label-width="120px" label-position="left">
<el-form-item label="迟到判定(分钟)">
<el-input-number v-model="rules.lateMinutes" :min="1" :max="60" size="default" />
</el-form-item>
<el-form-item label="缺勤预警阈值(%)">
<el-slider v-model="rules.absentWarning" :min="5" :max="30" :step="5" show-stops />
</el-form-item>
<el-form-item label="迟到预警阈值(%)">
<el-slider v-model="rules.lateWarning" :min="5" :max="30" :step="5" show-stops />
</el-form-item>
</el-form>
</div>
<!-- 考勤时段设置 -->
<div class="rule-card">
<div class="rule-card-header">
<el-icon :size="18" color="#1890ff"><Clock /></el-icon>
<h4>考勤时段设置</h4>
</div>
<el-form label-width="120px" label-position="left">
<el-form-item label="上午考勤时段">
<el-time-picker
v-model="rules.morningTime"
is-range
range-separator="-"
start-placeholder="开始"
end-placeholder="结束"
format="HH:mm"
size="default"
/>
</el-form-item>
<el-form-item label="下午考勤时段">
<el-time-picker
v-model="rules.afternoonTime"
is-range
range-separator="-"
start-placeholder="开始"
end-placeholder="结束"
format="HH:mm"
size="default"
/>
</el-form-item>
<el-form-item label="晚自习时段">
<el-time-picker
v-model="rules.eveningTime"
is-range
range-separator="-"
start-placeholder="开始"
end-placeholder="结束"
format="HH:mm"
size="default"
/>
</el-form-item>
</el-form>
</div>
<!-- 异常预警规则 -->
<div class="rule-card">
<div class="rule-card-header">
<el-icon :size="18" color="#f5222d"><Bell /></el-icon>
<h4>异常预警规则</h4>
</div>
<el-form label-width="140px" label-position="left">
<el-form-item label="连续缺勤预警(次)">
<el-input-number v-model="rules.continuousAbsent" :min="2" :max="10" size="default" />
</el-form-item>
<el-form-item label="专注度低阈值(%)">
<el-input-number v-model="rules.lowFocus" :min="30" :max="80" :step="5" size="default" />
</el-form-item>
<el-form-item label="异常行为频次阈值">
<el-input-number v-model="rules.abnormalFreq" :min="1" :max="20" size="default" />
</el-form-item>
<el-form-item label="邮件通知管理员">
<el-switch v-model="rules.emailNotify" active-color="#52c41a" />
</el-form-item>
<el-form-item label="短信通知班主任">
<el-switch v-model="rules.smsNotify" active-color="#52c41a" />
</el-form-item>
</el-form>
</div>
<!-- 课程特殊规则 -->
<div class="rule-card rule-card-full">
<div class="rule-card-header">
<el-icon :size="18" color="#722ed1"><Notebook /></el-icon>
<h4>课程特殊考勤规则</h4>
</div>
<el-table :data="courseRules" stripe>
<el-table-column prop="courseName" label="课程名称" min-width="160" />
<el-table-column prop="attendanceType" label="考勤方式" width="120" align="center">
<template #default="{ row }">
<el-tag size="small">{{ row.attendanceType }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="absentThreshold" label="缺勤阈值" width="100" align="center" />
<el-table-column prop="autoFlag" label="自动标记" width="100" align="center">
<template #default="{ row }">
<el-switch v-model="row.autoFlag" size="small" active-color="#52c41a" />
</template>
</el-table-column>
<el-table-column label="操作" width="120" align="center">
<template #default>
<el-button link type="primary" size="small">编辑</el-button>
<el-button link type="danger" size="small">删除</el-button>
</template>
</el-table-column>
</el-table>
<div style="margin-top: 12px">
<el-button :icon="Plus" size="small" @click="addCourseRule"></el-button>
</div>
</div>
</div>
<div class="save-bar">
<el-button type="primary" size="large" :icon="Check" @click="saveRules"></el-button>
<el-button size="large" @click="resetRules"></el-button>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { ElMessage } from 'element-plus'
import { Plus, Check } from '@element-plus/icons-vue'
const rules = ref({
lateMinutes: 10,
absentWarning: 10,
lateWarning: 15,
morningTime: [new Date(2024, 0, 1, 8, 0), new Date(2024, 0, 1, 12, 0)],
afternoonTime: [new Date(2024, 0, 1, 14, 0), new Date(2024, 0, 1, 17, 30)],
eveningTime: [new Date(2024, 0, 1, 19, 0), new Date(2024, 0, 1, 21, 30)],
continuousAbsent: 3,
lowFocus: 50,
abnormalFreq: 5,
emailNotify: true,
smsNotify: false
})
const courseRules = ref([
{ courseName: '高等数学A', attendanceType: '人脸签到', absentThreshold: '10%', autoFlag: true },
{ courseName: '大学英语B', attendanceType: '人脸签到', absentThreshold: '15%', autoFlag: true },
{ courseName: '计算机导论', attendanceType: '人脸+码签到', absentThreshold: '10%', autoFlag: true }
])
const saveRules = () => {
ElMessage.success('考勤规则保存成功,已实时生效')
}
const resetRules = () => {
ElMessage.info('已恢复默认配置')
}
const addCourseRule = () => {
ElMessage.info('添加课程规则功能开发中')
}
</script>
<style lang="scss" scoped>
.rules-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
margin-bottom: 20px;
}
.rule-card {
background: #ffffff;
border-radius: 8px;
padding: 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
&.rule-card-full {
grid-column: 1 / -1;
}
}
.rule-card-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 20px;
padding-bottom: 12px;
border-bottom: 1px solid #f0f0f0;
h4 {
font-size: 15px;
font-weight: 600;
color: #262626;
}
}
.save-bar {
display: flex;
gap: 12px;
justify-content: flex-end;
.el-button {
border-radius: 8px;
min-width: 120px;
}
}
@media (max-width: 1200px) {
.rules-grid {
grid-template-columns: 1fr;
}
}
</style>

@ -0,0 +1,24 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { fileURLToPath, URL } from 'node:url'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
},
css: {
preprocessorOptions: {
scss: {
additionalData: `@use "@/styles/variables.scss" as *;`
}
}
},
server: {
host: '0.0.0.0',
port: 3000,
open: true
}
})
Loading…
Cancel
Save