Compare commits
25
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
690d329371 | ||
|
|
6a63a7cb8f | ||
|
|
f90aefc8e3 | ||
|
|
c1fe0783fb | ||
|
|
3de71aa99b | ||
|
|
8fe3e2ef8c | ||
|
|
13afb22060 | ||
|
|
dbbabc56eb | ||
|
|
5e3f151ce7 | ||
|
|
ab647c67ba | ||
|
|
e8ccc7d3ba | ||
|
|
b685ff5dc0 | ||
|
|
9c8b0f900a | ||
|
|
1af6005a55 | ||
|
|
fea12895c1 | ||
|
|
599fed01b5 | ||
|
|
e0d1b2b769 | ||
|
|
71452fcfd2 | ||
|
|
eee4c10155 | ||
|
|
f611c15a6e | ||
|
|
941031e1b4 | ||
|
|
59f8fad4ca | ||
|
|
f4d0c7d97a | ||
|
|
e37ff9f8d1 | ||
|
|
c81f097188 |
@@ -127,6 +127,10 @@ class NoticeApi {
|
||||
flowWithdraw(serid: number) {
|
||||
return postForm<void>(`${NOTICE}/flow/Withdraw`, { serid });
|
||||
}
|
||||
/** 办件进度嵌入页地址(Drawer iframe 直嵌;pageUrl 含短时 token 现取现发,勿缓存复用) */
|
||||
flowEmbed(serid: number) {
|
||||
return get<{ pageUrl: string }>(`${NOTICE}/flow/Embed`, { serid });
|
||||
}
|
||||
/** 应用级流程绑定(应用设置 > 流程绑定;发通知页据此判断「提交审核」还是「发送」) */
|
||||
getFlowBinding() {
|
||||
return get<FlowBindingVo>(`${NOTICE}/flow/Binding`);
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024"><path d="M859.977 336.128H608.251V171.489c0-41.159-33.318-74.836-74.039-74.836h-44.421c-40.729 0-74.044 33.677-74.044 74.836v164.639H164.023c-16.289 0-29.616 13.473-29.616 29.936v149.669c0 16.469 13.326 29.942 29.616 29.942h14.803v231.241c0 65.859-14.803 120.493-14.803 120.493 0 16.459 13.324 29.938 29.611 29.938h636.729c16.291 0 29.615-13.481 29.615-29.938 0 0-14.81-56.882-14.81-123.486V545.675h14.81c16.287 0 29.616-13.477 29.616-29.938V366.064c-0.001-16.469-13.323-29.936-29.617-29.936zM682.282 897.403V699.089c0-5.989-5.18-11.225-11.103-11.225h-7.407c-5.917 0-11.103 5.236-11.103 11.225v198.314H534.212V699.089c0-5.989-5.181-11.225-11.116-11.225H515.7c-5.918 0-11.106 5.236-11.106 11.225v198.314H378.731V699.089c0-5.989-5.186-11.225-11.111-11.225h-7.399c-5.918 0-11.106 5.236-11.106 11.225v198.314H193.634s14.812-53.884 14.812-120.487V545.675h607.113v231.241c0 66.606 14.804 120.487 14.804 120.487H682.282z m177.695-396.634c0 8.233-6.66 14.968-14.81 14.968H178.826c-8.145 0-14.807-6.735-14.807-14.968V381.028c0-8.235 6.662-14.964 14.807-14.964H445.36V178.971c0-29.184 22.956-52.382 51.835-52.382h29.61c28.876 0 51.828 23.199 51.828 52.382v187.093h266.535c8.15 0 14.81 6.729 14.81 14.964v119.741z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -115,3 +115,16 @@ h4.subtitle {
|
||||
margin-right: 4px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
/* 修复表格幻影省略号(2026-10-10):.ivu-table-cell 基础样式自带 text-overflow:ellipsis,
|
||||
tooltip 列(.ivu-tooltip 行内块包装 + width:100%)与操作列(图标外包 Tooltip)未溢出也常显「...」。
|
||||
恢复组件预期的块级/flex 渲染层级即可;内层 .ivu-table-cell-tooltip-content 的真省略号与
|
||||
悬浮 tooltip 不受影响(已在 dev:mock 实测:短内容无省略号、长标题正常截断+悬浮提示)。 */
|
||||
/* 两条同为 3 类特异性(0,3,0):后者居后胜出 → tooltip 列(.ivu-table-cell-tooltip)最终 flex
|
||||
(组件预期布局),操作列裸 .ivu-tooltip 包装取 block。 */
|
||||
.ivu-table .ivu-table-cell > .ivu-tooltip {
|
||||
display: block;
|
||||
}
|
||||
.ivu-table .ivu-table-cell > .ivu-table-cell-tooltip {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
@@ -92,16 +92,18 @@ export default {
|
||||
display: block;
|
||||
.upload-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: flex-start; /* 提示语与按钮首行对齐(列表换行后不再垂直居中整块) */
|
||||
}
|
||||
:deep(.ivu-upload) {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap; /* 允许内部列表换行 */
|
||||
align-items: center;
|
||||
}
|
||||
/* 文件列表整体另起一行,每项独占一行,不再与按钮挤在一起 */
|
||||
:deep(.ivu-upload-list) {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
width: 100%; /* 文件列表整体独占下一行 */
|
||||
margin-top: 4px;
|
||||
}
|
||||
:deep(.ivu-upload-list-file) {
|
||||
padding: 4px 0;
|
||||
@@ -110,7 +112,7 @@ export default {
|
||||
margin-left: 10px;
|
||||
color: #f90;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
line-height: 32px; /* 与默认 Button 等高,紧贴按钮右侧 */
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
<script lang="ts" setup>
|
||||
/* 默认头像三态组件(YschTheme 公共源,scripts/sync-apps.mjs 下发三端 src/components/UserAvatar.vue):
|
||||
头像 URL 非空 → <img>;否则 style==="text" 且能取到字 → 首字;否则 → 剪影(纯 CSS/SVG,零资源文件)。
|
||||
取值链/取字契约见《设置页修复与默认头像落地方案》§10.1/§10.2;样式全在本文件 <style scoped>(唯一 CSS 源,
|
||||
不进通道 icons.src.css——通道 CSS 对 UserHome 不可达,内联分发才能消双份漂移,见同方案 §10.4)。
|
||||
根元素即 .yh-avatar 圆:尺寸内联必须挂圆本身(挂外层 span 会被 flex 拉成椭圆,§12 踩坑)。 */
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
|
||||
import { pickAvatarText, resolveAvatarStyle } from "@/utils/avatar";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** 头像 URL:非空直接 <img>(三态第一优先) */
|
||||
src?: string | null;
|
||||
/** 取字来源(首字态用):经 pickAvatarText 判定,取不到字回落剪影 */
|
||||
name?: string | null;
|
||||
/** 直径 px;font-size = round(size * 0.44) */
|
||||
size?: number;
|
||||
/** 配色场景:light=页面浅底(品牌实底+白内容);brand=header 品牌色底(剪影 S1 玻璃底 / 首字 S2 白底反色,§10.3 分档) */
|
||||
placement?: "light" | "brand";
|
||||
}>(),
|
||||
{ src: null, name: null, size: 28, placement: "light" },
|
||||
);
|
||||
|
||||
const text = computed(() => (props.src ? "" : pickAvatarText(props.name)));
|
||||
/* 外观偏好异步补齐(yh-theme-init 在挂载后拉服务端真源收敛 localStorage 键)会在
|
||||
document 上派发 yh-appearance-updated;tick 自增让 style 依赖失效重读键,三态即时切换(无需刷新)。 */
|
||||
const appearanceTick = ref(0);
|
||||
const onAppearanceUpdated = () => { appearanceTick.value += 1; };
|
||||
onMounted(() => document.addEventListener("yh-appearance-updated", onAppearanceUpdated));
|
||||
onBeforeUnmount(() => document.removeEventListener("yh-appearance-updated", onAppearanceUpdated));
|
||||
const style = computed(() => {
|
||||
appearanceTick.value;
|
||||
return resolveAvatarStyle();
|
||||
});
|
||||
/** 首字态 = style==="text" 且能取到字(契约 §10.2:二者缺一即剪影,取到字但档位是剪影时绝不能出字) */
|
||||
const showText = computed(() => style.value === "text" && !!text.value);
|
||||
|
||||
const rootClass = computed(() => [
|
||||
"yh-avatar",
|
||||
props.placement === "brand" ? "yh-avatar--brand" : "yh-avatar--light",
|
||||
{ "is-text": showText.value },
|
||||
]);
|
||||
|
||||
const rootStyle = computed(() => ({
|
||||
width: `${props.size}px`,
|
||||
height: `${props.size}px`,
|
||||
fontSize: `${Math.round(props.size * 0.44)}px`,
|
||||
}));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span :class="rootClass" :style="rootStyle">
|
||||
<img v-if="src" class="yh-avatar__img" :src="src" alt="" />
|
||||
<template v-else-if="showText">{{ text }}</template>
|
||||
<svg v-else class="yh-avatar__sil" viewBox="0 0 1024 1024" aria-hidden="true">
|
||||
<circle cx="512" cy="399" r="174" />
|
||||
<ellipse cx="512" cy="946" rx="420" ry="240" />
|
||||
</svg>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.yh-avatar {
|
||||
position: relative;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
flex: 0 0 auto;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
user-select: none;
|
||||
}
|
||||
.yh-avatar__img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
/* 剪影 SVG:容器圆 overflow:hidden 裁切肩部溢出(禁用 clipPath);overflow:visible 放行 viewBox 外形状 */
|
||||
.yh-avatar__sil {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: visible;
|
||||
fill: currentColor;
|
||||
}
|
||||
/* 页面内(浅底)——品牌实底 + 白内容 */
|
||||
.yh-avatar--light {
|
||||
background: var(--brand, #577afb);
|
||||
color: #fff;
|
||||
}
|
||||
/* header(品牌色底)——与页面内反向:剪影档 = S1 半透明白,首字档 = S2 纯白底 + 品牌色字 */
|
||||
.yh-avatar--brand {
|
||||
background: rgba(255, 255, 255, 0.22);
|
||||
color: #fff;
|
||||
}
|
||||
.yh-avatar--brand.is-text {
|
||||
background: #fff;
|
||||
color: var(--brand, #577afb);
|
||||
}
|
||||
</style>
|
||||
+41
-22
@@ -1,9 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onBeforeMount, watch, computed } from "vue";
|
||||
import { ref, onBeforeMount, onBeforeUnmount, onMounted, watch, computed } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { Message } from "view-ui-plus";
|
||||
import { resolveMenuPage } from "@/utils/menuRoute";
|
||||
import ThemeSwitcher from "@/components/ThemeSwitcher.vue";
|
||||
import UserAvatar from "@/components/UserAvatar.vue";
|
||||
import defaultLogo from "@/assets/image/yunhai-logo.svg";
|
||||
|
||||
const props = defineProps<{ layoutData: Record<string, any> }>();
|
||||
const route = useRoute();
|
||||
@@ -23,6 +25,27 @@ const modList = computed(() => props.layoutData?.modList || []);
|
||||
|
||||
const pathNavStyle = computed(() => (isAlone.value ? { left: "0", width: "100%" } : null));
|
||||
|
||||
// 顶栏 Logo:localStorage["yh-logo-url"](平台键,yh-theme-init 异步补齐段写入)优先,本仓资产兜底——绝无空 src/裂图。
|
||||
// 刷新:mounted 读一次 + 监听 yh-appearance-updated(服务端偏好到达后即时切换,无需刷新页面)。
|
||||
const logoUrl = ref<string>(defaultLogo);
|
||||
const readLogoUrl = () => {
|
||||
let v: string | null = null;
|
||||
try {
|
||||
v = localStorage.getItem("yh-logo-url");
|
||||
} catch {
|
||||
v = null;
|
||||
}
|
||||
logoUrl.value = v || defaultLogo;
|
||||
};
|
||||
const onLogoError = () => {
|
||||
if (logoUrl.value !== defaultLogo) logoUrl.value = defaultLogo;
|
||||
};
|
||||
onMounted(() => {
|
||||
readLogoUrl();
|
||||
document.addEventListener("yh-appearance-updated", readLogoUrl);
|
||||
});
|
||||
onBeforeUnmount(() => document.removeEventListener("yh-appearance-updated", readLogoUrl));
|
||||
|
||||
onBeforeMount(() => {
|
||||
routeChange(route);
|
||||
});
|
||||
@@ -121,7 +144,8 @@ const linkMenu = (item: any) => {
|
||||
};
|
||||
|
||||
const linkUserInfo = () => {
|
||||
window.location.href = baseHost.value + "/v/userinfo";
|
||||
// 头像/姓名点击统一跳 UserHome 设置页「个人信息」pane(默认头像轮 2026-09-30,原 /v/userinfo 已下线语义)
|
||||
window.location.href = baseHost.value + "/userhome/front/index.html#/settings?pane=profile";
|
||||
};
|
||||
const logout = () => {
|
||||
let url = window.location.href;
|
||||
@@ -137,11 +161,9 @@ const childChange = (index: number) => {
|
||||
|
||||
/** 菜单图标:读取用户中心模块管理配置的 YschIcons 图标名(GetUserMod 的 icon 字段,如 list → icon-list)。
|
||||
* icons.css(ysch-icons 掩码版,基类 iconfont,类名 icon-<名称>)现由中央静态通道下发;
|
||||
* 平台约定留空不下发图标,此时返回空串由模板 v-if 不渲染 */
|
||||
const getMenuIcon = (item?: any): string => {
|
||||
const icon = String(item?.icon || "").trim();
|
||||
return icon ? `icon-${icon}` : "";
|
||||
};
|
||||
* 平台未配置图标时统一兜底 icon-list(2026-10-09 定稿:菜单项恒有图标,保整列缩进/行高一致) */
|
||||
const getMenuIcon = (item?: any): string =>
|
||||
`icon-${String(item?.icon || "").trim() || "list"}`;
|
||||
|
||||
function linkHomePage() {
|
||||
if (baseHost.value) {
|
||||
@@ -157,12 +179,12 @@ function linkHomePage() {
|
||||
<div class="layout-wrap">
|
||||
<header class="header-container fix">
|
||||
<!-- 云海 logo 徽标(结构与 Cms SchoolLayout 对齐,皮肤见通道 base.layout.css .logo-badge) -->
|
||||
<span class="logo-badge fl"><img src="@/assets/image/yunhai-logo.svg" alt="云海" /></span>
|
||||
<span class="logo-badge fl"><img :src="logoUrl" alt="云海" @error="onLogoError" /></span>
|
||||
<div class="delimiter fl align-middle"></div>
|
||||
<h4 class="fl align-middle">{{ appName }}</h4>
|
||||
<div class="school-bg"></div>
|
||||
<div class="header-aside fr">
|
||||
<img :src="userInfo.avatar" v-if="userInfo.avatar" class="fl align-middle" @click="linkUserInfo" />
|
||||
<UserAvatar class="fl align-middle" :src="userInfo.avatar" :name="userInfo.name" :size="28" placement="brand" @click="linkUserInfo" />
|
||||
<a class="name fl align-middle text-ellipsis" href="javascript:void(0);" @click="linkUserInfo">
|
||||
{{ userInfo.name }}
|
||||
</a>
|
||||
@@ -171,8 +193,8 @@ function linkHomePage() {
|
||||
<i class="iconfont icon-logout"></i>
|
||||
</a>
|
||||
</div>
|
||||
<!-- 配色切换器(fr 顺序:DOM 后置即位于用户胶囊左侧) -->
|
||||
<div class="theme-cap fr"><ThemeSwitcher /></div>
|
||||
<!-- 配色切换器已移除(oapi 代理轮 2026-10-08 拍板):设置入口收敛在 UserHome 设置中心,
|
||||
本地切换会被服务端生效值收敛打回,不留半残入口 -->
|
||||
</header>
|
||||
<div class="left-menu" v-if="!isAlone">
|
||||
<div class="menu-top-box"></div>
|
||||
@@ -182,7 +204,7 @@ function linkHomePage() {
|
||||
<li>
|
||||
<a href="javascript:;" @click="childChange(index)">
|
||||
<i :class="item.childState ? 'arrow-down' : 'arrow-up'"></i>
|
||||
<i v-if="getMenuIcon(item)" :class="['iconfont', getMenuIcon(item)]"></i>
|
||||
<i :class="['iconfont', getMenuIcon(item)]"></i>
|
||||
{{ item.name }}
|
||||
</a>
|
||||
</li>
|
||||
@@ -202,7 +224,7 @@ function linkHomePage() {
|
||||
</template>
|
||||
<li v-else :class="pageOf(item) == currPath ? 'active' : ''">
|
||||
<a href="javascript:;" @click="linkMenu(item)">
|
||||
<i v-if="getMenuIcon(item)" :class="['iconfont', getMenuIcon(item)]"></i>
|
||||
<i :class="['iconfont', getMenuIcon(item)]"></i>
|
||||
{{ item.name }}
|
||||
</a>
|
||||
</li>
|
||||
@@ -266,20 +288,17 @@ function linkHomePage() {
|
||||
.header-container .logo-badge {
|
||||
margin: 0 10px 0 0;
|
||||
}
|
||||
.theme-cap {
|
||||
height: var(--topbar-h);
|
||||
margin-right: 6px;
|
||||
}
|
||||
/* 面包屑「首页」垂直居中:骨架遗留的 .path-nav .icon-category{position:relative;top:1px}
|
||||
是给老 iconfont 字体字形做的基线补偿,换成 ysch mask 的 1em 方块后反而把图标顶偏;
|
||||
这里用 inline-flex 真居中并关掉该补偿(不动通道产物,覆盖收在本组件内) */
|
||||
.crumb-home {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
/* 面包屑「首页」与后续级目的文字对齐(2026-10-09 定稿):此前 .crumb-home 用 inline-flex,
|
||||
行内级盒的基线取首项——16px 无字图标(其基线=方块底缘),整盒被顶高约 4px,首页文字
|
||||
比相邻级目高。改回普通行内流:图标(.iconfont 基类 inline-block,基线=底缘)以 -0.125em
|
||||
基线偏移做光学居中,文字与相邻级目共用同一行盒基线,天然对齐 */
|
||||
.crumb-home .iconfont {
|
||||
position: static;
|
||||
top: 0;
|
||||
margin-right: 4px;
|
||||
vertical-align: -0.125em;
|
||||
}
|
||||
</style>
|
||||
@@ -64,6 +64,10 @@ export interface NoticeVo {
|
||||
smsFlag: number;
|
||||
/** 接收人列表(JSON) */
|
||||
receiveList: string | null;
|
||||
/** 附件ID串("1,2,3";后端 Lombok 序列化名为 upFile,对齐旧 .NET UpFile 语义) */
|
||||
upFile?: string | null;
|
||||
/** @deprecated 旧 .NET 接口的 PascalCase 附件字段,仅作读取兼容 */
|
||||
UpFile?: string | null;
|
||||
createBy: string | null;
|
||||
createTime: string | null;
|
||||
updateBy: string | null;
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* 默认头像样式(首字 / 剪影)公共源(YschTheme,scripts/sync-apps.mjs 下发三端 src/utils/avatar.ts)。
|
||||
* 取值链与主题同构(theme-core.js resolveEffectiveThemeState 的合成链写法):
|
||||
* URL ?yh-avatar=text|silhouette(仅预览/排障覆盖:只影响读、绝不写回)
|
||||
* > localStorage["yh-avatar"](用户自选,纯字符串)
|
||||
* > localStorage["yh-avatar-sch-default"](学校默认,UserHome 登录后写入)
|
||||
* > "text"(系统默认)
|
||||
* 值域恒为 "text" | "silhouette",键缺失/值非法均静默回落下一级,不报错不阻塞渲染。
|
||||
* 渲染三态与取字规则的唯一契约见 UserHome《设置页修复与默认头像落地方案》§10.1/§10.2。
|
||||
*/
|
||||
|
||||
export type AvatarStyle = "text" | "silhouette";
|
||||
|
||||
interface StorageLike {
|
||||
getItem(k: string): string | null;
|
||||
setItem(k: string, v: string): void;
|
||||
removeItem(k: string): void;
|
||||
}
|
||||
|
||||
export const AVATAR_STORAGE_KEY = "yh-avatar";
|
||||
|
||||
/** 学校默认头像存储键(UserHome 登录时写入;值格式与用户键相同,仅无"用户自选"语义) */
|
||||
export const SCHOOL_DEFAULT_AVATAR_STORAGE_KEY = "yh-avatar-sch-default";
|
||||
|
||||
export const AVATAR_STYLES: readonly AvatarStyle[] = ["text", "silhouette"];
|
||||
|
||||
export const DEFAULT_AVATAR_STYLE: AvatarStyle = "text";
|
||||
|
||||
/** 值合法才采用(用户键/学校键同一判据;脏数据不生效) */
|
||||
function readAvatarStyleOrNull(raw: string | null): AvatarStyle | null {
|
||||
return raw === "text" || raw === "silhouette" ? raw : null;
|
||||
}
|
||||
|
||||
/** URL 覆盖参数(?yh-avatar=text|silhouette):非法值忽略,且只影响"读哪一档"、绝不写回 localStorage */
|
||||
function readAvatarOverride(): AvatarStyle | null {
|
||||
try {
|
||||
const search = (globalThis.location && globalThis.location.search) || "";
|
||||
const m = new RegExp("[?&]yh-avatar=([^&]*)").exec(search);
|
||||
return m ? readAvatarStyleOrNull(decodeURIComponent(m[1])) : null;
|
||||
} catch {
|
||||
/* 无 location 环境(测试/SSR):无覆盖 */
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生效样式合成(与 resolveEffectiveThemeState 同构):
|
||||
* URL 覆盖 > localStorage["yh-avatar"](用户自选)> localStorage["yh-avatar-sch-default"](学校默认)
|
||||
* > DEFAULT_AVATAR_STYLE。
|
||||
*/
|
||||
export function resolveAvatarStyle(storage: StorageLike | undefined = globalThis.localStorage): AvatarStyle {
|
||||
let style: AvatarStyle | null = null;
|
||||
if (storage) {
|
||||
style = readAvatarStyleOrNull(storage.getItem(AVATAR_STORAGE_KEY));
|
||||
if (!style) style = readAvatarStyleOrNull(storage.getItem(SCHOOL_DEFAULT_AVATAR_STORAGE_KEY));
|
||||
}
|
||||
if (!style) style = DEFAULT_AVATAR_STYLE;
|
||||
return readAvatarOverride() || style;
|
||||
}
|
||||
|
||||
/** 保存用户自选(直写 localStorage,无响应式;调用方操作后自行刷新 UI) */
|
||||
export function saveAvatarStyle(style: AvatarStyle, storage: StorageLike | undefined = globalThis.localStorage): void {
|
||||
try {
|
||||
storage?.setItem(AVATAR_STORAGE_KEY, style);
|
||||
} catch {
|
||||
/* 隐私模式/配额失败静默:仅丢持久化,不丢本次会话生效 */
|
||||
}
|
||||
}
|
||||
|
||||
/** 跟随学校默认 = 删用户自选键(之后回落学校默认/系统默认) */
|
||||
export function clearAvatarStyle(storage: StorageLike | undefined = globalThis.localStorage): void {
|
||||
try {
|
||||
storage?.removeItem(AVATAR_STORAGE_KEY);
|
||||
} catch {
|
||||
/* 隐私模式静默 */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取字(顺序判定,先命中先返回):含 "@" 先截 "@" 前段(邮箱取首字母);
|
||||
* 首字符必须用展开 [...s][0](禁用 charAt(0),避免代理对截半出乱码)。
|
||||
* ASCII 字母转大写;纯数字回落 ""(剪影);符号/emoji/空白回落 ""(剪影);其余(CJK 等)原样。
|
||||
*/
|
||||
export function pickAvatarText(name: string | null | undefined): string {
|
||||
let s = String(name ?? "");
|
||||
const at = s.indexOf("@");
|
||||
if (at > -1) s = s.slice(0, at);
|
||||
const ch = [...s.trim()][0];
|
||||
if (ch === undefined) return "";
|
||||
if (/^[A-Za-z]$/.test(ch)) return ch.toUpperCase();
|
||||
if (/^[0-9]$/.test(ch)) return "";
|
||||
if (/^[\p{P}\p{S}\s]$/u.test(ch)) return "";
|
||||
return ch;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* 流程示意图 PNG 显示口径(2026-10-10 用户反馈:嵌入页图过大,同 flow-center/front 同名工具):
|
||||
* 服务端按 SCALE=2 出图(视网膜清晰度),按自然尺寸渲染等于整体放大一倍。
|
||||
* 须按逻辑尺寸(自然宽一半)显示;超宽长图收缩到容器宽。
|
||||
* 用法:<img ref="diagramEl" :src="..." @load="onDiagramLoad">(图均为打开时挂载,@load 必触发)。
|
||||
*/
|
||||
export function fitDiagram(el: HTMLImageElement | null | undefined): void {
|
||||
if (!el || !el.naturalWidth) return;
|
||||
const want = el.naturalWidth / 2;
|
||||
const cap = (el.parentElement?.clientWidth || 0) - 8;
|
||||
el.style.width = Math.round(Math.min(want, cap > 120 ? cap : want)) + "px";
|
||||
}
|
||||
+1
-1
@@ -100,7 +100,7 @@ export default class Utils {
|
||||
"div",
|
||||
{
|
||||
class: `operating-btn iconfont ${iconClass}`,
|
||||
onClick: callback,
|
||||
onClick: disabled ? null : callback,
|
||||
},
|
||||
null,
|
||||
),
|
||||
|
||||
+73
-1
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* 本文件由 YschTheme scripts/gen-theme.mjs 从 assets-src/pc/theme-core.js 生成(版本 20260929-1112-1b1724d),勿手改。
|
||||
* 本文件由 YschTheme scripts/gen-theme.mjs 从 assets-src/pc/theme-core.js 生成(版本 20261009-1655-488a3c3),勿手改。
|
||||
* 分发方式:YschTheme `npm run sync` 拷进各端 src/utils/theme.ts(与 ThemeSwitcher.vue 配套,MD5 对齐报告)。
|
||||
* 运行时档位类名由 /static/js/yh-theme-init.js 在 <head> 内先行挂好,本模块只负责读写与切换。
|
||||
*/
|
||||
@@ -25,6 +25,9 @@ export const THEME_NAMES: readonly ThemeName[] = ["blue", "ocean", "ink", "viole
|
||||
|
||||
export const THEME_STORAGE_KEY = "yh-theme";
|
||||
|
||||
/* 学校默认档存储键(UserHome 登录时写入;值格式与 yh-theme 完全相同,仅无"用户自选"语义) */
|
||||
export const SCHOOL_DEFAULT_STORAGE_KEY = "yh-theme-sch-default";
|
||||
|
||||
export const DEFAULT_THEME_STATE: ThemeState = {
|
||||
theme: "blue",
|
||||
sideDeep: true,
|
||||
@@ -49,6 +52,75 @@ export function parseThemeState(raw: string | null): ThemeState {
|
||||
}
|
||||
}
|
||||
|
||||
/** 严格读取:四字段全部合法才返回状态,否则 null(合成链"值非法→回落下一级"用;用户键仍走宽松解析保现状) */
|
||||
function readThemeStateOrNull(raw: string | null): ThemeState | null {
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const o = JSON.parse(raw);
|
||||
if (!o || typeof o !== "object") return null;
|
||||
if (!THEME_NAMES.includes(o.theme)) return null;
|
||||
if (typeof o.sideDeep !== "boolean" || typeof o.gradOn !== "boolean" || typeof o.sideFollow !== "boolean") return null;
|
||||
return { theme: o.theme, sideDeep: o.sideDeep, gradOn: o.gradOn, sideFollow: o.sideFollow };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** URL 覆盖参数(?yh-theme= / ?yh-side=deep|light / ?yh-grad=on|off):仅预览/排障用,
|
||||
非法值忽略(对应字段不覆盖),且只影响"读哪一档"、绝不写回 localStorage。 */
|
||||
function readThemeOverrides(): { theme?: ThemeName; sideDeep?: boolean; gradOn?: boolean } | null {
|
||||
let out = null;
|
||||
try {
|
||||
const search = (globalThis.location && globalThis.location.search) || "";
|
||||
const get = (name) => {
|
||||
const m = new RegExp("[?&]" + name + "=([^&]*)").exec(search);
|
||||
return m ? decodeURIComponent(m[1]) : null;
|
||||
};
|
||||
const t = get("yh-theme");
|
||||
if (t && THEME_NAMES.includes(t)) {
|
||||
out = out || {};
|
||||
out.theme = t;
|
||||
}
|
||||
const s = get("yh-side");
|
||||
if (s === "deep" || s === "light") {
|
||||
out = out || {};
|
||||
out.sideDeep = s === "deep";
|
||||
}
|
||||
const g = get("yh-grad");
|
||||
if (g === "on" || g === "off") {
|
||||
out = out || {};
|
||||
out.gradOn = g === "on";
|
||||
}
|
||||
} catch {
|
||||
/* 无 location 环境(测试/SSR):无覆盖 */
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生效档合成(唯一实现,init 启动段与 UserHome theme.ts 共用):
|
||||
* URL 覆盖 > localStorage["yh-theme"](用户自选,存在即用——宽松修补,与历史行为一致)
|
||||
* > localStorage["yh-theme-sch-default"](学校默认,整体合法才生效)
|
||||
* > DEFAULT_THEME_STATE。
|
||||
* 键不存在/解析失败/值非法均静默回落,不报错不阻塞首帧。
|
||||
*/
|
||||
export function resolveEffectiveThemeState(storage: StorageLike | undefined = globalThis.localStorage): ThemeState {
|
||||
let state = null;
|
||||
if (storage) {
|
||||
const userRaw = storage.getItem(THEME_STORAGE_KEY);
|
||||
if (userRaw) state = parseThemeState(userRaw);
|
||||
if (!state) state = readThemeStateOrNull(storage.getItem(SCHOOL_DEFAULT_STORAGE_KEY));
|
||||
}
|
||||
if (!state) state = { ...DEFAULT_THEME_STATE };
|
||||
const ov = readThemeOverrides();
|
||||
if (ov) {
|
||||
if (ov.theme) state = { ...state, theme: ov.theme };
|
||||
if (ov.sideDeep != null) state = { ...state, sideDeep: ov.sideDeep };
|
||||
if (ov.gradOn != null) state = { ...state, gradOn: ov.gradOn };
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
export function loadThemeState(storage: StorageLike | undefined = globalThis.localStorage): ThemeState {
|
||||
if (!storage) return { ...DEFAULT_THEME_STATE };
|
||||
return parseThemeState(storage.getItem(THEME_STORAGE_KEY));
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { NoticeDetailVo, NoticeRequest, UpFileVo, FlowBindingVo } from "@/t
|
||||
import UserCheckInput from "@/components/UserCheckInput.vue";
|
||||
import NoticeUpload from "@/components/NoticeUpload.vue";
|
||||
import config from "@/assets/config/host.config";
|
||||
import { fitDiagram } from "@/utils/diagramImg";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
@@ -85,11 +86,21 @@ onMounted(async () => {
|
||||
}
|
||||
});
|
||||
|
||||
/** 流程图:flow-center 示意图深链(Notice 指定到人场景用人员视图) */
|
||||
/** 流程图 Drawer(后端 DiagramDef 中转 PNG:同源免 token;与设置页同款,修复旧深链 404) */
|
||||
const flowDiagramVisible = ref(false);
|
||||
const flowDiagramEl = ref<HTMLImageElement | null>(null);
|
||||
const flowDiagramSrc = computed(() =>
|
||||
flowDiagramVisible.value && auditFlowGuid.value
|
||||
? `${config.serviceHost}/api/notice/flow/DiagramDef?flowGuid=${encodeURIComponent(auditFlowGuid.value)}`
|
||||
: "",
|
||||
);
|
||||
function openFlowDiagram() {
|
||||
if (auditFlowGuid.value) {
|
||||
window.open(`${config.flowHost}/#/diagram?flowGuid=${auditFlowGuid.value}&mode=user`, "_blank");
|
||||
}
|
||||
if (!auditFlowGuid.value) return;
|
||||
flowDiagramVisible.value = true;
|
||||
}
|
||||
function onFlowDiagramError() {
|
||||
flowDiagramVisible.value = false;
|
||||
Message.error("流程图加载失败(流程可能未发布或绑定已解除)");
|
||||
}
|
||||
|
||||
/* ============ 富文本编辑器(wangeditor v5 for vue3) ============ */
|
||||
@@ -408,6 +419,14 @@ async function saveEditAndLeave(data: NoticeRequest) {
|
||||
<Button @click="isModal.timiSend = false" style="margin-left: 8px">取消</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<!-- 流程图 Drawer(后端 DiagramDef 同源 PNG;竖版长图容器内纵向滚动) -->
|
||||
<Drawer v-model="flowDiagramVisible" title="流程示意图" :width="860" :transfer="false"
|
||||
:styles="{ body: { padding: '0' } }">
|
||||
<div class="flow-diagram-frame">
|
||||
<img ref="flowDiagramEl" v-if="flowDiagramSrc" :src="flowDiagramSrc" alt="流程示意图" @load="fitDiagram(flowDiagramEl)" @error="onFlowDiagramError" />
|
||||
</div>
|
||||
</Drawer>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -439,4 +458,18 @@ async function saveEditAndLeave(data: NoticeRequest) {
|
||||
.bottom-btn {
|
||||
margin-top: 6px;
|
||||
}
|
||||
/* 流程图 Drawer:浅底色 + 纵向滚动(竖版长图),图片撑满可读宽度 */
|
||||
.flow-diagram-frame {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
background: #f5f7fa;
|
||||
padding: 12px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.flow-diagram-frame img {
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Message, Tooltip } from "view-ui-plus";
|
||||
import { noticeApi } from "@/api/module/notice";
|
||||
import type { NoticeDetailVo, NoticeQuery, NoticeVo, NoticePageResult } from "@/types/notice";
|
||||
import FilePreviewModal from "@/components/FilePreviewModal.vue";
|
||||
import SvgIcon from "@/components/SvgIcon.vue";
|
||||
import Utils from "@/utils";
|
||||
|
||||
/* ============ 左侧列表 ============ */
|
||||
@@ -111,7 +112,7 @@ function openPreview(item: { name?: string; filename?: string; fileid?: number;
|
||||
};
|
||||
}
|
||||
|
||||
/* 附件图标(按扩展名,图标名对齐旧版 matchSuffix → icon-word/excel/ppt/pdf1/txt/tupian1/yasuo/weizhi) */
|
||||
/* 附件图标(按扩展名,图标名对齐旧版 matchSuffix → icon-word/excel/ppt/pdf1/txt/tupian/yasuo/weizhi) */
|
||||
function getFileIcon(name: string) {
|
||||
const suffix = String(name || "").split(".").pop()?.toLowerCase() || "";
|
||||
if (["doc", "docx", "wps"].includes(suffix)) return "icon-word";
|
||||
@@ -119,7 +120,8 @@ function getFileIcon(name: string) {
|
||||
if (["ppt", "pptx"].includes(suffix)) return "icon-ppt";
|
||||
if (["pdf"].includes(suffix)) return "icon-pdf1";
|
||||
if (["txt"].includes(suffix)) return "icon-txt";
|
||||
if (["jpg", "jpeg", "png", "gif", "bmp"].includes(suffix)) return "icon-tupian1";
|
||||
// 平台 icons.css 的 icon-tupian 是线性「图片」图标;icon-tupian1 是旧彩色图转单色,看不出是图片
|
||||
if (["jpg", "jpeg", "png", "gif", "bmp"].includes(suffix)) return "icon-tupian";
|
||||
if (["zip", "rar"].includes(suffix)) return "icon-yasuo";
|
||||
return "icon-weizhi";
|
||||
}
|
||||
@@ -171,7 +173,7 @@ function shortDate(date: string) {
|
||||
<div class="total">
|
||||
<span>未读<span class="num">{{ page.unreadCount || 0 }}</span>条</span>
|
||||
<Tooltip content="一键已读" placement="top" transfer>
|
||||
<i class="iconfont icon-saoba clear-icon" @click="allRead"></i>
|
||||
<SvgIcon class="clear-icon" name="read-all" :size="18" color="var(--text-3)" hover-color="var(--brand)" @click="allRead" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div class="search-box">
|
||||
@@ -211,11 +213,11 @@ function shortDate(date: string) {
|
||||
</div>
|
||||
</div>
|
||||
<div class="title-content">
|
||||
<p>来源:{{ detail.DeptName }}</p>
|
||||
<p>发布人:{{ detail.TeaName }}</p>
|
||||
<p>发布时间:{{ detail.EditDate }}</p>
|
||||
<p><span class="meta-label">来源</span>:{{ detail.DeptName }}</p>
|
||||
<p><span class="meta-label">发布人</span>:{{ detail.TeaName }}</p>
|
||||
<p><span class="meta-label">发布时间</span>:{{ detail.EditDate }}</p>
|
||||
<p>
|
||||
<span>附件:{{ (detail.ChaoSend || []).length }}个</span>
|
||||
<span class="meta-label">附件</span>:{{ (detail.ChaoSend || []).length }}个
|
||||
<span class="to-file" v-if="detail.ChaoSend && detail.ChaoSend.length > 0" @click="downAll">全部下载</span>
|
||||
</p>
|
||||
</div>
|
||||
@@ -306,12 +308,6 @@ function shortDate(date: string) {
|
||||
}
|
||||
.clear-icon {
|
||||
cursor: pointer;
|
||||
font-size: 18px;
|
||||
color: var(--text-3);
|
||||
transition: color 0.15s;
|
||||
&:hover {
|
||||
color: var(--brand);
|
||||
}
|
||||
}
|
||||
}
|
||||
.search-box {
|
||||
@@ -443,6 +439,13 @@ function shortDate(date: string) {
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
/* 标签两端撑开:冒号纵向对齐、值从同一列起(宽度取「发布时间」4 字) */
|
||||
.meta-label {
|
||||
display: inline-block;
|
||||
width: 4em;
|
||||
text-align: justify;
|
||||
text-align-last: justify;
|
||||
}
|
||||
.to-file {
|
||||
margin-left: 8px;
|
||||
color: var(--brand);
|
||||
|
||||
@@ -14,10 +14,9 @@
|
||||
<span class="opinion">{{ row._opinion }}</span>
|
||||
</template>
|
||||
<template #action="{ row }">
|
||||
<Button size="small" type="primary" :loading="acting" @click="approve(row)">通过</Button>
|
||||
<Button size="small" type="error" style="margin-left: 6px" @click="openReject(row)"
|
||||
>驳回</Button
|
||||
>
|
||||
<span class="operating-link" @click="openEmbed(row)">查看详情</span>
|
||||
<span class="operating-link" @click="approve(row)">通过</span>
|
||||
<span class="operating-link text-error" @click="openReject(row)">驳回</span>
|
||||
</template>
|
||||
</Table>
|
||||
</div>
|
||||
@@ -43,6 +42,9 @@
|
||||
<template #status="{ row }">
|
||||
<Tag :color="instStatusColor(row.status)">{{ instStatusText(row.status) }}</Tag>
|
||||
</template>
|
||||
<template #instAction="{ row }">
|
||||
<Button size="small" @click="openEmbed(row)">审批记录</Button>
|
||||
</template>
|
||||
</Table>
|
||||
</div>
|
||||
<div class="page-panel" v-if="instPage.total > instPage.pageSize">
|
||||
@@ -66,6 +68,13 @@
|
||||
</p>
|
||||
<Input v-model="rejectOpinion" type="textarea" :rows="3" placeholder="驳回意见(必填)" />
|
||||
</Modal>
|
||||
|
||||
<!-- 审批记录 Drawer(flow-center 嵌入页 iframe 撑满:状态/基本信息/流程示意图/轨迹/审批流水/表单六块) -->
|
||||
<Drawer v-model="embedVisible" title="审批记录" :width="740" :transfer="false" :styles="{ body: { padding: '0' } }">
|
||||
<div class="embed-frame-wrap">
|
||||
<iframe v-if="embedPageUrl" :src="embedPageUrl" title="办件进度"></iframe>
|
||||
</div>
|
||||
</Drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -77,6 +86,7 @@ import approvalApi, {
|
||||
type ApprovalInstance,
|
||||
type ApprovalTodoTask,
|
||||
} from "@/api/module/approval";
|
||||
import { noticeApi } from "@/api/module/notice";
|
||||
|
||||
/**
|
||||
* 我的待审核(应用内审批,第三步):待办列表 + 办件视角,通过/驳回走本应用后端代理
|
||||
@@ -110,7 +120,7 @@ const todoCols = [
|
||||
{ title: "摘要", key: "_summary", minWidth: 200, ellipsis: true, tooltip: true },
|
||||
{ title: "当前节点", key: "nodeName", width: 110 },
|
||||
{ title: "到达时间", key: "arriveTime", width: 170, render: fmtTime },
|
||||
{ title: "操作", slot: "action", width: 150 },
|
||||
{ title: "操作", slot: "action", width: 190, align: "center" },
|
||||
];
|
||||
|
||||
const instCols = [
|
||||
@@ -119,8 +129,28 @@ const instCols = [
|
||||
{ title: "提交人", key: "submitterName", width: 100 },
|
||||
{ title: "提交时间", key: "submitTime", width: 170, render: fmtTime },
|
||||
{ title: "办结时间", key: "finishTime", width: 170, render: fmtTime },
|
||||
{ title: "操作", slot: "instAction", width: 100 },
|
||||
];
|
||||
|
||||
/* 审批记录 Drawer(嵌入页 iframe):pageUrl 含 10 分钟短时 token,每次打开现取,
|
||||
* 不缓存、不落 storage(既有安全纪律);业务错(尚未进入流程/无权限 403)原文提示,不打断列表 */
|
||||
const embedVisible = ref(false);
|
||||
const embedPageUrl = ref("");
|
||||
|
||||
async function openEmbed(row: ApprovalInstance | ApprovalTodoTask) {
|
||||
try {
|
||||
const res = await noticeApi.flowEmbed(Number(row.bizId)).send();
|
||||
embedPageUrl.value = res?.pageUrl || "";
|
||||
if (!embedPageUrl.value) {
|
||||
Message.error("审批记录地址获取失败");
|
||||
return;
|
||||
}
|
||||
embedVisible.value = true;
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || "审批记录打开失败");
|
||||
}
|
||||
}
|
||||
|
||||
function fmtTime(h: any, p: any) {
|
||||
return h(
|
||||
"span",
|
||||
@@ -176,6 +206,7 @@ function load() {
|
||||
}
|
||||
|
||||
async function approve(row: ApprovalTodoTask) {
|
||||
if (acting.value) return; // 链接式操作无 :loading 反馈,用 acting 拦截连点重复提交
|
||||
acting.value = true;
|
||||
try {
|
||||
await approvalApi.approve(row.taskId).send();
|
||||
@@ -229,4 +260,14 @@ onMounted(load);
|
||||
.filter-bar {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
/* 审批记录 Drawer:iframe 撑满,嵌入页内部自己滚动 */
|
||||
.embed-frame-wrap {
|
||||
height: 100%;
|
||||
}
|
||||
.embed-frame-wrap iframe {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,34 +1,26 @@
|
||||
<template>
|
||||
<div class="page-wrapper yh-fill">
|
||||
<div class="main-container">
|
||||
<div class="table-region" ref="regionRef">
|
||||
<Table :columns="cols" :data="rows" :height="tableH || undefined" :loading="loading"
|
||||
no-data-text="暂无抄送通知">
|
||||
<template #title="{ row }">
|
||||
<a v-if="row.title" class="notice-link" @click="openDetail(row)">{{ row.title }}</a>
|
||||
<span v-else class="notice-gone">通知已删除</span>
|
||||
</template>
|
||||
<template #status="{ row }">
|
||||
<Tag v-if="row.auditStatusDesc && row.auditStatusDesc !== '通知已删除'"
|
||||
:color="statusColor(row.auditStatus)">{{ row.auditStatusDesc }}</Tag>
|
||||
<Tag v-else-if="row.noticeStatus === 1" color="green">已发布</Tag>
|
||||
<Tag v-else>—</Tag>
|
||||
</template>
|
||||
</Table>
|
||||
</div>
|
||||
<div class="page-panel" v-if="page.total > page.pageSize">
|
||||
<Page class="page-box fr" :current="page.pageIndex" :page-size="page.pageSize" :total="page.total"
|
||||
show-total @on-change="load" />
|
||||
</div>
|
||||
<TablePageAlovaNew ref="listRef" :data-api="fetchPage" :columns="cols">
|
||||
<template #title="{ row }">
|
||||
<a v-if="row.title" class="notice-link" @click="openDetail(row)">{{ row.title }}</a>
|
||||
<span v-else class="notice-gone">通知已删除</span>
|
||||
</template>
|
||||
<template #status="{ row }">
|
||||
<Tag v-if="row.auditStatusDesc && row.auditStatusDesc !== '通知已删除'"
|
||||
:color="statusColor(row.auditStatus)">{{ row.auditStatusDesc }}</Tag>
|
||||
<Tag v-else-if="row.noticeStatus === 1" color="green">已发布</Tag>
|
||||
<Tag v-else>—</Tag>
|
||||
</template>
|
||||
</TablePageAlovaNew>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from "vue";
|
||||
import { ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { Message } from "view-ui-plus";
|
||||
import { useElementHeight } from "@/utils/hook/useElementHeight";
|
||||
import TablePageAlovaNew from "@/components/TablePageAlovaNew.vue";
|
||||
import ccApi, { type CcItem } from "@/api/module/cc";
|
||||
|
||||
/**
|
||||
@@ -37,12 +29,9 @@ import ccApi, { type CcItem } from "@/api/module/cc";
|
||||
* 编辑视角(type=0)不标读:未发布通知的阅读统计无意义。
|
||||
*/
|
||||
const router = useRouter();
|
||||
const loading = ref(false);
|
||||
const rows = ref<CcItem[]>([]);
|
||||
const page = ref({ total: 0, pageIndex: 1, pageSize: 10 });
|
||||
|
||||
const regionRef = ref<HTMLElement | null>(null);
|
||||
const tableH = useElementHeight(regionRef);
|
||||
const listRef = ref<InstanceType<typeof TablePageAlovaNew> | null>(null);
|
||||
// usePagination 的 handler 必须返回 Method(组件内部统一 send 并吃全局响应拦截),返回 Promise 会卡在 loading
|
||||
const fetchPage = (p: { pageIndex: number; pageSize: number }) => ccApi.my(p.pageIndex, p.pageSize);
|
||||
|
||||
const cols = [
|
||||
{ title: "通知标题", slot: "title", minWidth: 260, ellipsis: true, tooltip: true },
|
||||
@@ -69,21 +58,6 @@ function statusColor(auditStatus: number | null) {
|
||||
function openDetail(row: CcItem) {
|
||||
router.push({ path: "/list/detail", query: { serid: row.noticeId, type: 0 } });
|
||||
}
|
||||
|
||||
async function load(p = 1) {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await ccApi.my(p, page.value.pageSize).send();
|
||||
page.value = res;
|
||||
rows.value = res.content || [];
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || "抄送列表加载失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => load(1));
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { noticeApi } from "@/api/module/notice";
|
||||
import type { NoticeDetailVo, NextPrevVo, ReadGroup, FlowTraceVo } from "@/types/notice";
|
||||
import FilePreviewModal from "@/components/FilePreviewModal.vue";
|
||||
import Utils from "@/utils";
|
||||
import { fitDiagram } from "@/utils/diagramImg";
|
||||
import config from "@/assets/config/host.config";
|
||||
|
||||
const route = useRoute();
|
||||
@@ -77,6 +78,7 @@ async function flowAction(action: "withdraw" | "urge") {
|
||||
|
||||
/* 流程图弹窗(2026-09-17:后端同源出图 /flow/Diagram,img 免 token 免跨域,不再深链 flow-center) */
|
||||
const diagramVisible = ref(false);
|
||||
const diagramEl = ref<HTMLImageElement | null>(null);
|
||||
function openFlowDiagram() {
|
||||
if (!detail.value?.flowGuid || !detail.value?.flowInstanceId) return;
|
||||
diagramVisible.value = true;
|
||||
@@ -90,6 +92,24 @@ function onDiagramError() {
|
||||
diagramVisible.value = false;
|
||||
Message.error("流程图加载失败(可能已过期,请刷新页面重试)");
|
||||
}
|
||||
|
||||
/* 办件进度嵌入页(iframe 免签嵌入 flow-center 统一进度页):每次打开现取 pageUrl——
|
||||
* token 10 分钟短时效,不缓存、不落 localStorage 复用;嵌入页内部自己滚动 */
|
||||
const embedVisible = ref(false);
|
||||
const embedPageUrl = ref("");
|
||||
async function openFlowEmbed() {
|
||||
if (!detail.value?.flowInstanceId) return;
|
||||
embedVisible.value = true;
|
||||
embedPageUrl.value = "";
|
||||
try {
|
||||
const res = await noticeApi.flowEmbed(serid.value).send();
|
||||
embedPageUrl.value = res?.pageUrl || "";
|
||||
} catch (e: any) {
|
||||
embedVisible.value = false;
|
||||
Message.error(e?.msg || e?.message || "嵌入页地址获取失败");
|
||||
}
|
||||
}
|
||||
|
||||
const readGroups = ref<ReadGroup[]>([]);
|
||||
const showRead = ref(false);
|
||||
const prevId = ref(0);
|
||||
@@ -189,7 +209,7 @@ function downAll() {
|
||||
function displayName(item: { name?: string; filename?: string }) {
|
||||
return item.name || item.filename || "";
|
||||
}
|
||||
/* 附件图标(按扩展名,图标名对齐旧版 matchSuffix → icon-word/excel/ppt/pdf1/txt/tupian1/yasuo/weizhi) */
|
||||
/* 附件图标(按扩展名,图标名对齐旧版 matchSuffix → icon-word/excel/ppt/pdf1/txt/tupian/yasuo/weizhi) */
|
||||
function getFileIcon(name: string) {
|
||||
const suffix = String(name || "").split(".").pop()?.toLowerCase() || "";
|
||||
if (["doc", "docx", "wps"].includes(suffix)) return "icon-word";
|
||||
@@ -197,7 +217,8 @@ function getFileIcon(name: string) {
|
||||
if (["ppt", "pptx"].includes(suffix)) return "icon-ppt";
|
||||
if (["pdf"].includes(suffix)) return "icon-pdf1";
|
||||
if (["txt"].includes(suffix)) return "icon-txt";
|
||||
if (["jpg", "jpeg", "png", "gif", "bmp"].includes(suffix)) return "icon-tupian1";
|
||||
// 平台 icons.css 的 icon-tupian 是线性「图片」图标;icon-tupian1 是旧彩色图转单色,看不出是图片
|
||||
if (["jpg", "jpeg", "png", "gif", "bmp"].includes(suffix)) return "icon-tupian";
|
||||
if (["zip", "rar"].includes(suffix)) return "icon-yasuo";
|
||||
return "icon-weizhi";
|
||||
}
|
||||
@@ -265,6 +286,13 @@ function openPreview(item: { name?: string; filename?: string; fileid?: number;
|
||||
<span v-if="flowTrace.currentNodes" class="flow-nodes">当前节点:{{ flowTrace.currentNodes }}</span>
|
||||
<div class="flow-ops">
|
||||
<Button v-if="detail.flowGuid" size="small" @click="openFlowDiagram">查看流程图</Button>
|
||||
<Button
|
||||
v-if="detail.flowInstanceId"
|
||||
size="small"
|
||||
style="margin-left: 8px"
|
||||
@click="openFlowEmbed"
|
||||
>审批进度</Button
|
||||
>
|
||||
<Button
|
||||
v-if="detail.flowInstanceId && flowTrace.auditStatus === 10"
|
||||
size="small"
|
||||
@@ -320,10 +348,17 @@ function openPreview(item: { name?: string; filename?: string; fileid?: number;
|
||||
<!-- 流程图弹窗(后端同源出图,免 token 免跨域) -->
|
||||
<Modal v-model="diagramVisible" title="流程示意图" :width="860" footer-hide>
|
||||
<div class="diagram-wrap">
|
||||
<img v-if="diagramSrc" :src="diagramSrc" alt="流程示意图" @error="onDiagramError" />
|
||||
<img v-if="diagramSrc" ref="diagramEl" :src="diagramSrc" alt="流程示意图" @load="fitDiagram(diagramEl)" @error="onDiagramError" />
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<!-- 办件进度嵌入页(Drawer 内 iframe 撑满,嵌入页内部滚动;token 每次打开现取) -->
|
||||
<Drawer v-model="embedVisible" title="审批进度" :width="740" :transfer="false" :styles="{ body: { padding: '0' } }">
|
||||
<div class="embed-frame-wrap">
|
||||
<iframe v-if="embedPageUrl" :src="embedPageUrl" title="办件进度"></iframe>
|
||||
</div>
|
||||
</Drawer>
|
||||
|
||||
<div v-if="viewType !== 0" class="meta">
|
||||
<span>发文单位:{{ detail.DeptName || "—" }}</span>
|
||||
<span>发布人:{{ detail.TeaName || "—" }}</span>
|
||||
@@ -384,6 +419,19 @@ function openPreview(item: { name?: string; filename?: string; fileid?: number;
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 高度模型(2026-10-10 定稿):卡片撑满视口(与全站其他页一致),但必须能"随长文生长"。
|
||||
不能直接用通道 .yh-fill:其卡片是 flex:1(basis:0)+ min-height:420px,卡片被钉在容器高,
|
||||
而 .main-container 带 overflow:hidden(base.layout.css:222)→ 长文被裁掉且滚不到(最小复现已验证)。
|
||||
故此处 page-wrapper 转 flex 纵向(全局已给 min-height:100%),卡片 flex:1 0 auto(basis:auto)
|
||||
→ 短文撑满、长文随内容长高、滚动留在 main-wrapper 整页滚。 */
|
||||
.page-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.page-wrapper > .main-container {
|
||||
flex: 1 0 auto;
|
||||
min-height: 420px;
|
||||
}
|
||||
.ops-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -479,6 +527,17 @@ function openPreview(item: { name?: string; filename?: string; fileid?: number;
|
||||
}
|
||||
.diagram-wrap img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
/* 嵌入页 iframe 撑满 Drawer body(body padding 已由 :styles 置 0),嵌入页内部滚动 */
|
||||
.embed-frame-wrap {
|
||||
height: 100%;
|
||||
}
|
||||
.embed-frame-wrap iframe {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
}
|
||||
.meta {
|
||||
display: flex;
|
||||
@@ -541,6 +600,9 @@ function openPreview(item: { name?: string; filename?: string; fileid?: number;
|
||||
}
|
||||
.content {
|
||||
padding: 16px 0;
|
||||
/* 正文区最小高度:短通知别让附件/按钮紧贴一行字(旧版用 clientHeight-356 铺满视口,
|
||||
会把附件顶出首屏;这里只留"呼吸区",剩余空白落在按钮下方的卡片底部) */
|
||||
min-height: 200px;
|
||||
line-height: 1.8;
|
||||
word-break: break-all;
|
||||
color: var(--text);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch, h } from "vue";
|
||||
import { ref, computed, onMounted, watch, h, resolveComponent } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { useRequest } from "alova/client";
|
||||
import { Message } from "view-ui-plus";
|
||||
@@ -33,9 +33,10 @@ const auditStatusOptions = [
|
||||
{ value: -1, label: "无需审核" },
|
||||
];
|
||||
|
||||
function fmtDate(d: Date) {
|
||||
function fmtDate(d: Date | string) {
|
||||
const dt = new Date(d);
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||
return `${dt.getFullYear()}-${pad(dt.getMonth() + 1)}-${pad(dt.getDate())}`;
|
||||
}
|
||||
|
||||
/* ============ 分页(规范 §3:customPageCount 页长记忆) ============ */
|
||||
@@ -55,8 +56,9 @@ async function run() {
|
||||
pageSize: pageSize.value,
|
||||
orderByTime: "1",
|
||||
};
|
||||
if (startDt.value) params.startTime = startDt.value;
|
||||
if (endDt.value) params.endTime = endDt.value;
|
||||
// DatePicker 选过日期后 v-model 值是 Date 对象,后端 LocalDate 只认 yyyy-MM-dd → 统一格式化
|
||||
if (startDt.value) params.startTime = fmtDate(new Date(startDt.value));
|
||||
if (endDt.value) params.endTime = fmtDate(new Date(endDt.value));
|
||||
if (auditType.value !== "" && auditType.value !== null) params.auditStatus = Number(auditType.value);
|
||||
if (!isManage.value) {
|
||||
params.scope = "user";
|
||||
@@ -144,9 +146,10 @@ function del(row: NoticeVo) {
|
||||
}
|
||||
|
||||
/* 附件下载:逐个触发(间隔 500ms,同旧版) */
|
||||
function downloadFiles(row: NoticeVo & { UpFile?: string }) {
|
||||
if (!row.UpFile) return;
|
||||
row.UpFile.split(",").forEach((id: string, i: number) => {
|
||||
function downloadFiles(row: NoticeVo) {
|
||||
const upFile = row.upFile ?? row.UpFile;
|
||||
if (!upFile) return;
|
||||
upFile.split(",").forEach((id: string, i: number) => {
|
||||
setTimeout(() => {
|
||||
window.location.href = noticeApi.fileDownUrl(Number(id));
|
||||
}, i * 500);
|
||||
@@ -172,8 +175,9 @@ const columns = [
|
||||
render: (h: any, p: any) => {
|
||||
const st = p.row.auditStatus;
|
||||
if (st === null || st === undefined) return h("span", { style: "color:var(--text-3);" }, "—");
|
||||
const colors: Record<number, string> = { 0: "default", 10: "blue", 20: "green", 30: "red", 40: "orange" };
|
||||
return h("Tag", { color: colors[st] || "default" }, () => p.row.auditStatusDesc || "—");
|
||||
// 色系对齐流程侧:待提交灰 / 审批中橙 / 已通过绿 / 已驳回红 / 已撤回灰
|
||||
const colors: Record<number, string> = { 0: "default", 10: "warning", 20: "success", 30: "error", 40: "default" };
|
||||
return h(resolveComponent("Tag"), { color: colors[st] || "default" }, () => p.row.auditStatusDesc || "—");
|
||||
},
|
||||
},
|
||||
{ title: "已读人数", key: "readCount", width: 110, align: "left" },
|
||||
@@ -188,7 +192,7 @@ const columns = [
|
||||
width: 70,
|
||||
align: "center",
|
||||
render: (h: any, p: any) =>
|
||||
p.row.UpFile
|
||||
(p.row.upFile ?? p.row.UpFile)
|
||||
? Utils.operatingHandle(h, "附件下载", "icon-attachment", () => downloadFiles(p.row))
|
||||
: h("span", { style: "color:var(--text-3);" }, "无"),
|
||||
},
|
||||
@@ -198,12 +202,12 @@ const columns = [
|
||||
align: "center",
|
||||
fixed: "right",
|
||||
render: (h: any, p: any) => {
|
||||
// BP-R5-1:审批中(audit=10)禁止删除(后端 403)——不再渲染死按钮,审批通过/驳回/撤回后恢复
|
||||
const ops = [Utils.operatingHandle(h, "查看通知", "icon-browse", () => openDetail(p.row))];
|
||||
if (p.row.auditStatus !== 10) {
|
||||
ops.push(Utils.operatingHandle(h, "删除通知", "icon-ashbin", () => del(p.row)));
|
||||
}
|
||||
return ops;
|
||||
// BP-R5-1:审批中(audit=10)禁止删除(后端 403)——渲染禁用态而非隐藏,保持操作列对齐
|
||||
const delDisabled = p.row.auditStatus === 10;
|
||||
return [
|
||||
Utils.operatingHandle(h, "查看通知", "icon-browse", () => openDetail(p.row)),
|
||||
Utils.operatingHandle(h, "删除通知", "icon-ashbin", () => del(p.row), delDisabled),
|
||||
];
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -9,6 +9,7 @@ import { settingApi, baseApi, noticeApi } from "@/api/module/notice";
|
||||
import type { DeptManageItem, ContactGroupPage, ContactUserPage, SchoolTreeNode, FlowOption } from "@/types/notice";
|
||||
import config from "@/assets/config/host.config";
|
||||
import Utils from "@/utils";
|
||||
import { fitDiagram } from "@/utils/diagramImg";
|
||||
import { useElementHeight } from "@/utils/hook/useElementHeight";
|
||||
|
||||
/* ============ Tab 切换(单卡片 yh-tabs-card 内含线性 Tabs,切换只 v-show 对应 tab-body) ============ */
|
||||
@@ -80,9 +81,30 @@ function unbindFlow() {
|
||||
});
|
||||
}
|
||||
|
||||
/** 流程图(flow-center 示意图深链,与发通知页同一入口) */
|
||||
function openFlowDiagram(guid: string) {
|
||||
if (guid) window.open(`${config.flowHost}/#/diagram?flowGuid=${guid}`, "_blank");
|
||||
/** 流程图 Drawer(后端 DiagramDef 中转 PNG:同源免 token 免跨域。修复旧深链 404——
|
||||
* 生产前端静态挂 /flow-center/front*,Caddy 无 /flow-center/ 根路由,window.open 深链必 404) */
|
||||
const flowDiagramVisible = ref(false);
|
||||
const flowDiagramEl = ref<HTMLImageElement | null>(null);
|
||||
const flowDiagramGuid = ref(""); // 打开抽屉时定格的流程 GUID(绑定态=boundFlowGuid,预览态=selectedGuid)
|
||||
const flowDiagramSrc = computed(() =>
|
||||
flowDiagramVisible.value && flowDiagramGuid.value
|
||||
? `${config.serviceHost}/api/notice/flow/DiagramDef?flowGuid=${encodeURIComponent(flowDiagramGuid.value)}`
|
||||
: "",
|
||||
);
|
||||
function openFlowDiagram() {
|
||||
if (!boundFlowGuid.value) return;
|
||||
flowDiagramGuid.value = boundFlowGuid.value;
|
||||
flowDiagramVisible.value = true;
|
||||
}
|
||||
/** 预览下拉当前选中(未必已保存)的流程 */
|
||||
function previewSelectedFlow() {
|
||||
if (!selectedGuid.value) return;
|
||||
flowDiagramGuid.value = selectedGuid.value;
|
||||
flowDiagramVisible.value = true;
|
||||
}
|
||||
function onFlowDiagramError() {
|
||||
flowDiagramVisible.value = false;
|
||||
Message.error("流程图加载失败(流程可能未发布或绑定已解除)");
|
||||
}
|
||||
|
||||
/** 当前绑定流程的节点摘要(下拉里回显) */
|
||||
@@ -508,6 +530,7 @@ const groupH = useElementHeight(groupRegionRef);
|
||||
<template v-if="flowCanEdit">
|
||||
<Button type="primary" :loading="bindingSaving" @click="saveFlowBinding">保存绑定</Button>
|
||||
<Button v-if="boundFlowGuid" @click="unbindFlow">解除绑定</Button>
|
||||
<Button v-if="selectedGuid" @click="previewSelectedFlow">查看流程图</Button>
|
||||
</template>
|
||||
<span v-else class="readonly-hint">仅本校管理员可修改流程绑定</span>
|
||||
</div>
|
||||
@@ -517,7 +540,7 @@ const groupH = useElementHeight(groupRegionRef);
|
||||
已绑定
|
||||
<Tag color="blue">{{ boundFlowName || boundFlowGuid }}</Tag>
|
||||
<span class="node-summary">{{ boundFlowSummary }}</span>
|
||||
<Button type="text" size="small" @click="openFlowDiagram(boundFlowGuid)">查看流程图</Button>
|
||||
<Button type="text" size="small" @click="openFlowDiagram">查看流程图</Button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<Tag>未绑定</Tag>
|
||||
@@ -528,6 +551,14 @@ const groupH = useElementHeight(groupRegionRef);
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 流程图 Drawer(后端 DiagramDef 同源 PNG;竖版长图容器内纵向滚动) -->
|
||||
<Drawer v-model="flowDiagramVisible" title="流程示意图" :width="860" :transfer="false"
|
||||
:styles="{ body: { padding: '0' } }">
|
||||
<div class="flow-diagram-frame">
|
||||
<img ref="flowDiagramEl" v-if="flowDiagramSrc" :src="flowDiagramSrc" alt="流程示意图" @load="fitDiagram(flowDiagramEl)" @error="onFlowDiagramError" />
|
||||
</div>
|
||||
</Drawer>
|
||||
|
||||
<!-- 新增发文部门 Modal(编辑走 TypeSetBox 内联编辑) -->
|
||||
<Modal v-model="deptModal" :mask-closable="false" width="450px" title="新增部门">
|
||||
<Form ref="deptFormRef" :model="deptForm" :rules="deptRule" :label-width="110">
|
||||
@@ -627,4 +658,18 @@ const groupH = useElementHeight(groupRegionRef);
|
||||
margin-top: 16px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
/* 流程图 Drawer:浅底色 + 纵向滚动(竖版长图),图片撑满可读宽度 */
|
||||
.flow-diagram-frame {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
background: #f5f7fa;
|
||||
padding: 12px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.flow-diagram-frame img {
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user