feat(theme): 接入云海子主题通道(A 类免插件,规范 v1.12 §2/§3)

本次同时包含会话前遗留的本地未提交改动(作业模块 M5 相关页面/接口/样式调整、
host.config 的 SSO 兜底推导、.gitignore 等),按团队确认一并提交。

主题通道部分:
- index.html 插 A 类 loader:唯一接入点,FOUC 门/manifest 预取/主题 link 追加与排序
  全由通道 yh-theme-init.js 承担,主题升级走 /static 通道,本应用无需重新构建
- vite.config 加 /static dev 代理(YH_THEME_PROXY_TARGET 可覆盖,默认线上通道)
- host.config 回写 __APP_CONFIG__.serviceHost(仅同源):init 的 appearance 请求有硬性
  同源限制,跨源会被直接跳过;本地 dev 保持空串走同源 /api 代理
- 顶栏头像接平台键 yh-avatar,监听 yh-appearance-updated,userInfo 兜底
- view-ui-plus 声明锁 1.3.24(对齐通道皮肤生成版本)
- .gitea/workflows/build.yml:CI 平台统一更新(构建容器名唯一化 + npm 走 npmmirror)
This commit is contained in:
lijing
2026-10-21 17:38:11 +08:00
parent 70bd3a247f
commit bb7a832290
32 changed files with 2294 additions and 740 deletions
+208 -147
View File
@@ -6,9 +6,7 @@ import { studentApi } from "@/api/module/studentHomework";
import type { BasePageResult, StudentHomeworkV2Vo, StudentHomeworkDetailV2Vo, AttachmentItem } from "@/types/homeworkV2";
import AttachmentUpload from "@/components/AttachmentUpload.vue";
import MyConfirm from "@/components/MyConfirm";
import config from "@/assets/config/host.config";
const serviceBase = config.isMock ? "" : config.serviceHost;
const route = useRoute();
const router = useRouter();
@@ -86,9 +84,22 @@ const STATUS_TEXT: Record<string, string> = {
RETURNED: "待订正",
REVIEWED: "已批阅",
};
/* 左侧色条用色。蓝色对齐主题 #5979EA(原 #2d8cf0 是另一套蓝,与 tab 下划线/主按钮不同色) */
const STATUS_COLOR: Record<string, string> = {
NOT_SUBMITTED: "#c5c8ce",
SUBMITTED: "#2d8cf0",
SUBMITTED: "#5979EA",
LATE_SUBMITTED: "#ff9900",
RETURNED: "#ed4014",
REVIEWED: "#19be6b",
};
/**
* 状态标签(Tag)配色。
* 「未提交」用 default(白底浅灰描边)——属"未产生动作"的静默态,与宿舍通知里
* 「仅通知 / 已截止」同款处理;其余为实心语义色。
*/
const STATUS_TAG: Record<string, string> = {
NOT_SUBMITTED: "default",
SUBMITTED: "#5979EA",
LATE_SUBMITTED: "#ff9900",
RETURNED: "#ed4014",
REVIEWED: "#19be6b",
@@ -99,6 +110,17 @@ function myStatus(x: StudentHomeworkV2Vo) {
function statusColor(x: StudentHomeworkV2Vo) {
return STATUS_COLOR[myStatus(x)];
}
function statusTag(x: StudentHomeworkV2Vo) {
return STATUS_TAG[myStatus(x)] || "default";
}
/** 已批阅摘要:分数、评语都空时返回空串(不渲染,避免出现「得分 --」) */
function gradeText(x: StudentHomeworkV2Vo) {
const parts: string[] = [];
const s = x.my?.score;
if (s !== null && s !== undefined && String(s) !== "") parts.push(`得分 ${s}`);
if (x.my?.comment) parts.push(x.my.comment);
return parts.join(" · ");
}
/** 仅查阅型(submitType 含 none) */
function isViewOnly(x: StudentHomeworkV2Vo) {
return (x.submitType || []).includes("none") || (x.submitType || []).length === 0;
@@ -190,42 +212,34 @@ function withdraw(row: StudentHomeworkV2Vo) {
}
/* ============ 图片放大预览 ============ */
const imgPreview = reactive({ open: false, url: "" });
function previewUrl(a: AttachmentItem) {
const u = a.fileUrl || "";
if (/^(https?:)?\/\//.test(u) || u.startsWith("data:")) return u;
return `${serviceBase}${u}`;
}
function zoom(url: string) {
imgPreview.url = url;
imgPreview.open = true;
}
/* 列表卡片不直接渲染附件缩略图(提交/查看走 /stu/detail),此处不再保留放大弹窗 */
</script>
<template>
<div class="page-wrapper">
<div class="main-container stu">
<!-- 顶部概览 -->
<!-- 顶部概览:轻量信息条(对齐宿舍通知学生端的浅蓝提示条语言,
替换原 91px 渐变大色块:省纵向空间,且不再与主题色 #5979EA 撞色) -->
<div class="stu-head">
<div class="sh-left">
<div class="sh-icon"><Icon type="md-book" size="26" color="#fff" /></div>
<div>
<div class="sh-title">我的作业</div>
<div class="sh-sub">按时提交,查看老师批阅与订正</div>
</div>
</div>
<div class="sh-chips">
<div class="chip"><b>{{ page.total }}</b><span>本页作业</span></div>
<Icon type="md-book" size="16" />
<span class="sh-title">我的作业</span>
<span class="sh-sub">按时提交,查看老师批阅与订正</span>
</div>
<div class="sh-count">共 <b>{{ page.total }}</b> 份作业</div>
</div>
<div class="fix mb-12">
<Tabs :value="tab" @on-click="onTab" class="fl stu-tabs">
<div class="stu-filter">
<!-- view-ui-plus 4.x 的 Tabs 只有 modelValue,写 :value 不生效(高亮会与控制逻辑脱钩) -->
<Tabs v-model="tab" @on-click="onTab" class="stu-tabs">
<TabPane v-for="t in TABS" :key="t.name" :name="t.name" :label="t.label"></TabPane>
</Tabs>
<!-- ★ 不要给这个输入框加 clearable:view-ui-plus 的 Input 模板里
清除图标(clearable) 是 v-if、搜索图标(search) 是 v-else-if,
一旦有输入内容清除图标就把放大镜顶掉 → 「点放大镜搜索」直接失效。
本行只做宽度与右对齐,交互保持原样。 -->
<Input
class="fr"
style="width: 220px"
class="stu-search"
search
suffix="ios-search"
placeholder="搜索作业标题"
@@ -241,43 +255,58 @@ function zoom(url: string) {
<p>该分类下暂无作业</p>
</div>
<div class="cards" v-else>
<div class="cards" :class="{ 'has-page': page.total > pageSize }" v-else>
<div class="card" v-for="row in rows" :key="row.id" :class="{ redo: myStatus(row) === 'RETURNED' }" :style="{ borderLeftColor: statusColor(row) }">
<div class="c-top" @click="goDetail(row)">
<span class="c-subj">{{ row.subject || "作业" }}</span>
<span class="c-title">{{ row.title }}</span>
<!-- 左:作业主体信息 -->
<div class="c-main">
<div class="c-top" @click="goDetail(row)">
<span class="c-subj">{{ row.subject || "作业" }}</span>
<span class="c-title" :title="row.title">{{ row.title }}</span>
<span v-if="!isViewOnly(row) && row.my?.isLate" class="c-late">逾期</span>
</div>
<div class="c-meta">
<span class="m-item"><Icon type="md-person" size="14" /> {{ row.teacherName || "—" }}</span>
<!-- 「已截止」只在还需要学生动手时标红(未提交/待订正,且非仅查阅);
已提交、已批阅、仅查阅的条目保持灰字,避免整屏红字像出错 -->
<span
class="m-item"
:class="{
overdue: dueInfo(row.deadlineAt).overdue && !isViewOnly(row) && ['NOT_SUBMITTED', 'RETURNED'].includes(myStatus(row)),
urgent: dueInfo(row.deadlineAt).urgent && myStatus(row) === 'NOT_SUBMITTED',
}"
>
<Icon type="md-time" size="14" />
{{ row.deadlineAt ? `截止 ${String(row.deadlineAt).slice(5, 16)} · ${dueInfo(row.deadlineAt).text}` : "无截止" }}
</span>
</div>
<!-- 待订正:老师评语强化展示 -->
<div class="c-grade warn" v-if="myStatus(row) === 'RETURNED'">
<Icon type="md-refresh" color="#ed4014" size="15" /> 老师要求订正:{{ row.my?.comment || "请订正后重新提交" }}
</div>
<!-- 已批阅:得分摘要(分数/评语都没有时不渲染) -->
<div class="c-grade" v-else-if="myStatus(row) === 'REVIEWED' && gradeText(row)">
<Icon type="md-checkmark-circle" color="#19be6b" size="15" /> {{ gradeText(row) }}
</div>
</div>
<!-- 右:状态 + 操作(对齐宿舍通知用 Tag 表达状态,替代原来的裸色文字)
★ Tag 不要写 size="small":view-ui-plus 4.x 的 Tag.size 只收 default/medium/large,
small 会被 validator 拦掉并刷 Vue warn(Button 才有 small,两者不一致) -->
<div class="c-side">
<Tag v-if="isViewOnly(row)" color="default" class="c-flag">仅查阅</Tag>
<template v-else>
<span v-if="row.my?.isLate" class="c-late">逾期</span>
<span class="c-status" :style="{ color: statusColor(row) }">{{ STATUS_TEXT[myStatus(row)] }}</span>
</template>
</div>
<div class="c-meta">
<span class="m-item"><Icon type="md-person" size="14" /> {{ row.teacherName || "—" }}</span>
<span class="m-item" :class="{ overdue: dueInfo(row.deadlineAt).overdue && myStatus(row) !== 'REVIEWED', urgent: dueInfo(row.deadlineAt).urgent && myStatus(row) === 'NOT_SUBMITTED' }">
<Icon type="md-time" size="14" /> 截止 {{ row.deadlineAt ? String(row.deadlineAt).slice(5, 16) : "—" }}({{ dueInfo(row.deadlineAt).text }})
</span>
</div>
<!-- 待订正:老师评语强化展示 -->
<div class="c-grade warn" v-if="myStatus(row) === 'RETURNED'">
<Icon type="md-refresh" color="#ed4014" size="16" /> 老师要求订正:{{ row.my?.comment || "请订正后重新提交" }}
</div>
<!-- 已批阅:得分摘要 -->
<div class="c-grade" v-else-if="myStatus(row) === 'REVIEWED'">
<Icon type="md-checkmark-circle" color="#19be6b" size="16" />
得分 {{ row.my?.score ?? "—" }}<template v-if="row.my?.comment"> · {{ row.my.comment }}</template>
</div>
<div class="c-actions">
<Button v-if="['NOT_SUBMITTED'].includes(myStatus(row)) && canSubmit(row)" type="primary" icon="md-cloud-upload" size="small" @click="openSubmit(row)">
{{ row.hwStatus === "CLOSED" ? "补交作业" : "提交作业" }}</Button>
<Button v-else-if="myStatus(row) === 'RETURNED' && canSubmit(row)" type="error" icon="md-refresh" size="small" @click="openSubmit(row)">重新提交</Button>
<template v-else-if="['SUBMITTED', 'LATE_SUBMITTED'].includes(myStatus(row)) && canSubmit(row) && row.hwStatus === 'PUBLISHED'">
<Button ghost icon="md-create" size="small" @click="openSubmit(row)">修改提交</Button>
<Button icon="md-undo" size="small" @click="withdraw(row)">撤回</Button>
</template>
<Button icon="md-eye" size="small" @click="goDetail(row)">{{ myStatus(row) === "REVIEWED" || myStatus(row) === "RETURNED" ? "查看批阅" : "查看详情" }}</Button>
<Tag v-else :color="statusTag(row)" class="c-status">{{ STATUS_TEXT[myStatus(row)] }}</Tag>
<div class="c-actions">
<Button v-if="['NOT_SUBMITTED'].includes(myStatus(row)) && canSubmit(row)" type="primary" icon="md-cloud-upload" size="small" @click="openSubmit(row)">
{{ row.hwStatus === "CLOSED" ? "补交作业" : "提交作业" }}</Button>
<Button v-else-if="myStatus(row) === 'RETURNED' && canSubmit(row)" type="error" icon="md-refresh" size="small" @click="openSubmit(row)">重新提交</Button>
<template v-else-if="['SUBMITTED', 'LATE_SUBMITTED'].includes(myStatus(row)) && canSubmit(row) && row.hwStatus === 'PUBLISHED'">
<!-- 必须带 type:裸 ghost 是白字白底,在白色卡片上等于隐形 -->
<Button type="primary" ghost icon="md-create" size="small" @click="openSubmit(row)">修改提交</Button>
<Button icon="md-undo" size="small" @click="withdraw(row)">撤回</Button>
</template>
<Button icon="md-eye" size="small" @click="goDetail(row)">{{ myStatus(row) === "REVIEWED" || myStatus(row) === "RETURNED" ? "查看批阅" : "查看详情" }}</Button>
</div>
</div>
</div>
</div>
@@ -302,79 +331,85 @@ function zoom(url: string) {
<Button type="primary" :loading="sub.submitting" icon="md-send" @click="submit">提交</Button>
</template>
</Modal>
<!-- 图片放大 -->
<Modal v-model="imgPreview.open" footer-hide title="图片预览" width="680px">
<div class="zoom-wrap"><img :src="imgPreview.url" /></div>
</Modal>
</div>
</div>
</template>
<style lang="scss" scoped>
/*
* 学生端列表版式对齐宿舍通知学生端(ParentNotices.vue / NoticeList.vue):
* 1) 铺满内容区(原来是 max-width:960px,1440 视口下右侧空 280px)
* 2) 顶部不做装饰性大色块,用浅蓝信息条(#e8f4ff + #d3e9ff 描边,与宿舍通知「我的回执」条同款)
* 3) 列表项改为「左信息 / 右状态+操作」两栏,把横向宽度用起来,行高从 ~118px 收到 ~80px
* 4) 状态用 Tag 表达(原来是裸色文字,「未提交」的 #c5c8ce 在白卡上几乎看不见)
* 功能与交互(tab 过滤、搜索、分页、提交/撤回/跳详情)一行未动。
*/
.stu {
max-width: 960px;
width: 100%;
}
/* ---------- 顶部信息条 ---------- */
.stu-head {
display: flex;
align-items: center;
justify-content: space-between;
background: linear-gradient(135deg, #2d8cf0, #5cadff);
border-radius: 10px;
padding: 16px 20px;
color: #fff;
margin-bottom: 14px;
box-shadow: 0 4px 14px rgba(45, 140, 240, 0.25);
gap: 16px;
background: #e8f4ff;
border: 1px solid #d3e9ff;
border-radius: 4px;
padding: 9px 14px;
margin-bottom: 12px;
.sh-left {
display: flex;
align-items: center;
gap: 12px;
}
.sh-icon {
width: 46px;
height: 46px;
border-radius: 10px;
background: rgba(255, 255, 255, 0.18);
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
min-width: 0;
color: #5979ea;
}
.sh-title {
font-size: 18px;
flex: none;
font-size: 15px;
font-weight: 600;
color: #17233d;
}
.sh-sub {
font-size: 12px;
opacity: 0.85;
margin-top: 2px;
color: #808695;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sh-chips {
display: flex;
gap: 10px;
.chip {
min-width: 66px;
text-align: center;
background: rgba(255, 255, 255, 0.16);
border-radius: 8px;
padding: 8px 10px;
b {
display: block;
font-size: 20px;
line-height: 1.1;
}
span {
font-size: 12px;
opacity: 0.9;
}
.sh-count {
flex: none;
font-size: 13px;
color: #515a6e;
b {
font-size: 16px;
color: #5979ea;
margin: 0 2px;
}
}
}
.stu-tabs {
margin-bottom: 0;
:deep(.ivu-tabs-bar) {
margin-bottom: 0;
/* ---------- 筛选行(tab 左 / 搜索右) ---------- */
.stu-filter {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 12px;
.stu-tabs {
flex: 1;
min-width: 0;
:deep(.ivu-tabs-bar) {
margin-bottom: 0;
}
}
.stu-search {
flex: none;
width: 240px;
}
}
.state {
padding: 30px 0;
text-align: center;
@@ -385,62 +420,80 @@ function zoom(url: string) {
}
}
}
/* ---------- 列表项 ---------- */
.cards {
/* 分页条是 position:absolute 钉在视口底部(72px 高、白底),列表在其下方滚动会被盖住;
有分页时垫出这段高度,让最后一条能滚到分页条上方(同 Table 页预留横向滚动条的道理) */
&.has-page {
padding-bottom: 52px;
}
}
.cards .card {
display: flex;
align-items: center;
gap: 16px;
background: #fff;
border: 1px solid #eceef1;
border-left: 4px solid #c5c8ce;
border-radius: 8px;
padding: 14px 16px;
margin-bottom: 12px;
transition: box-shadow 0.2s;
border: 1px solid #ebeef4;
border-left: 3px solid #c5c8ce;
border-radius: 4px;
padding: 12px 16px;
margin-bottom: 10px;
transition:
box-shadow 0.2s,
border-color 0.2s;
&:hover {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.06);
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.06);
}
&.redo {
border-color: #f5b0a6;
border-left-color: #ed4014;
background: #fffaf9;
}
/* 左:主体信息 */
.c-main {
flex: 1;
min-width: 0;
}
.c-top {
display: flex;
align-items: center;
gap: 10px;
gap: 8px;
min-width: 0;
cursor: pointer;
.c-subj {
background: #eaf3ff;
color: #2d8cf0;
border-radius: 4px;
padding: 2px 9px;
color: #5979ea;
border-radius: 3px;
padding: 1px 8px;
font-size: 12px;
line-height: 18px;
flex: none;
}
.c-title {
font-size: 16px;
font-size: 15px;
font-weight: 600;
color: #17233d;
}
.c-flag {
margin-left: auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.c-late {
background: #ff9900;
color: #fff;
border-radius: 3px;
font-size: 11px;
padding: 0 4px;
flex: none;
}
.c-status {
margin-left: auto;
font-size: 13px;
font-weight: 600;
line-height: 16px;
padding: 0 5px;
flex: none;
}
}
.c-meta {
display: flex;
gap: 18px;
margin-top: 8px;
flex-wrap: wrap;
gap: 4px 18px;
margin-top: 6px;
color: #808695;
font-size: 13px;
.m-item {
@@ -455,23 +508,38 @@ function zoom(url: string) {
color: #ff9900;
}
}
/* 评语条:inline-flex 让底色只包住内容,不拉满整行 */
.c-grade {
margin-top: 10px;
padding: 8px 12px;
background: #f6ffed;
border-radius: 6px;
color: #389e0d;
font-size: 13px;
display: flex;
display: inline-flex;
align-items: center;
gap: 6px;
max-width: 100%;
margin-top: 8px;
padding: 5px 10px;
background: #f6ffed;
border-radius: 4px;
color: #389e0d;
font-size: 12px;
line-height: 18px;
&.warn {
background: #fff1f0;
color: #cf1322;
}
}
/* 右:状态 + 操作 */
.c-side {
flex: none;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 8px;
.c-status,
.c-flag {
font-size: 12px;
}
}
.c-actions {
margin-top: 12px;
display: flex;
gap: 8px;
}
@@ -488,11 +556,4 @@ function zoom(url: string) {
gap: 4px;
}
}
.zoom-wrap {
text-align: center;
img {
max-width: 100%;
max-height: 62vh;
}
}
</style>