feat(appicon): AppIcon 资产库接入(应用网格矢量图标渲染,YschAppIcons 10-04 轮 P3)

- AppIcon.vue:矢量图标渲染组件(iconKey+iconColor → mask data-uri,无键无 URL 回退关键词映射占位)
- constants/appIconRules.ts:应用名关键词→图标键兜底映射(D4,两端各持一份,与 MobilePortal 关键词列一致;仅 icon_key 未配置且无 URL 时生效,有 URL 恒定位图零回归)
- AppList/SearchPanel/index:应用网格与搜索结果图标位换 AppIcon
- appicon.mask.css:资产库 mask 样式;yunhai-logo.svg:站点 Logo 矢量件
- main.scss/_app_container.scss:网格图标容器样式配套
依据:根目录《AppIcon资产库落地-执行报告-20261004.md》P3 UserHome(PC)接入
This commit is contained in:
chenxing
2026-10-06 20:20:32 +08:00
parent 93249cf5f5
commit 4d9da5e3a6
9 changed files with 226 additions and 7 deletions
+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
<path fill="#4aaff0" d="M512,585 C415,455 330,398 215,395 C185,395 160,415 160,450 L160,770 C160,815 190,838 240,845 C340,856 430,872 512,888 C594,872 684,856 784,845 C834,838 864,815 864,770 L864,450 C864,415 839,395 809,395 C694,398 609,455 512,585 Z"/>
<path fill="#4aaff0" d="M218,312 C335,292 462,352 514,462 C452,404 334,354 218,378 Z"/>
<circle fill="#4aaff0" cx="218" cy="345" r="33"/>
<path fill="#80d976" d="M508,462 C455,310 525,145 700,105 C758,250 668,425 508,462 Z"/>
<path fill="none" stroke="#4aaff0" stroke-width="44" stroke-linecap="round" d="M210,935 Q285.5,881 361,935 T512,935 T663,935 T814,935"/>
</svg>

After

Width:  |  Height:  |  Size: 701 B

+33
View File
@@ -0,0 +1,33 @@
/* appicon 图标(CSS mask + currentColor,data-uri 自包含),共 2 个。
用法 <i class="appicon appicon-<名>"></i>;命名空间隔离:类名恒为 .appicon*,无 .icon-*。 */
.appicon {
font-family: inherit;
font-style: normal;
font-size: 16px;
line-height: 1;
display: inline-block;
vertical-align: middle;
}
.appicon::before {
content: "" !important;
display: block;
width: 1em;
height: 1em;
background-color: currentColor;
-webkit-mask-position: center;
mask-position: center;
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
-webkit-mask-size: contain;
mask-size: contain;
}
.appicon-tongzhi::before {
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath d='M853.33 725.33H938.67V810.67H85.33V725.33H170.67V426.67C170.67 238.15 323.49 85.33 512 85.33C700.51 85.33 853.33 238.15 853.33 426.67V725.33ZM768 725.33V426.67C768 285.28 653.38 170.67 512 170.67C370.62 170.67 256 285.28 256 426.67V725.33H768ZM384 896H640V981.33H384V896Z' %3E%3C/path%3E%3C/svg%3E");
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath d='M853.33 725.33H938.67V810.67H85.33V725.33H170.67V426.67C170.67 238.15 323.49 85.33 512 85.33C700.51 85.33 853.33 238.15 853.33 426.67V725.33ZM768 725.33V426.67C768 285.28 653.38 170.67 512 170.67C370.62 170.67 256 285.28 256 426.67V725.33H768ZM384 896H640V981.33H384V896Z' %3E%3C/path%3E%3C/svg%3E");
}
.appicon-wenjuan::before {
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath d='M725.33 85.33V170.67H853.61C877.02 170.67 896 189.65 896 213.05V896.28C896 919.69 877.02 938.67 853.61 938.67H170.39C146.98 938.67 128 919.68 128 896.28V213.05C128 189.64 146.98 170.67 170.39 170.67H298.67V85.33H725.33ZM298.67 256H213.33V853.33H810.67V256H725.33V341.33H298.67V256ZM384 682.67V768H298.67V682.67H384ZM384 554.67V640H298.67V554.67H384ZM384 426.67V512H298.67V426.67H384ZM640 170.67H384V256H640V170.67Z' %3E%3C/path%3E%3C/svg%3E");
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath d='M725.33 85.33V170.67H853.61C877.02 170.67 896 189.65 896 213.05V896.28C896 919.69 877.02 938.67 853.61 938.67H170.39C146.98 938.67 128 919.68 128 896.28V213.05C128 189.64 146.98 170.67 170.39 170.67H298.67V85.33H725.33ZM298.67 256H213.33V853.33H810.67V256H725.33V341.33H298.67V256ZM384 682.67V768H298.67V682.67H384ZM384 554.67V640H298.67V554.67H384ZM384 426.67V512H298.67V426.67H384ZM640 170.67H384V256H640V170.67Z' %3E%3C/path%3E%3C/svg%3E");
}
@@ -56,3 +56,53 @@
// border-radius: 2px; // border-radius: 2px;
// background: rgba(255, 255, 255, 0.25); // background: rgba(255, 255, 255, 0.25);
} }
/* ===================== AppIcon 矢量图标三档 scene(方案 §4 渲染契约) =====================
* 图标资源只管形状,容器管底色与图标色;底色为端侧派生 token(不落库)。
* 形状类(.appicon-<名> 的 mask data-uri)来自全局 appicon.mask.css;
* AppIcon.vue 组件按 scene/tint 组合类名与内联覆盖(tint=application_info.icon_color 人工配置色)。
* 尺寸对齐上方 img 位图(60px / 18% 圆角),保证矢量与位图在网格里视觉一致。
*/
.appicon {
display: inline-block;
line-height: 1;
vertical-align: middle;
box-sizing: border-box;
}
.appicon-tile {
// PC 应用卡:方块底 + 白图标(对齐位图 60px/18%)
--appicon-bg: var(--brand, #366ef4); // 端侧主题 token(通道 CSS 不可达时兜底蓝)
width: 60px;
height: 60px;
border-radius: 18%;
background: var(--appicon-bg);
color: #fff;
font-size: 44px;
display: inline-flex;
align-items: center;
justify-content: center;
}
.appicon-circle {
// App 首页快捷:正圆底 + 白图标
--appicon-bg: var(--brand, #366ef4);
width: 50px;
height: 50px;
border-radius: 50%;
background: var(--appicon-bg);
color: #fff;
font-size: 36px;
display: inline-flex;
align-items: center;
justify-content: center;
}
.appicon-plain {
// 无底线性:图标色继承(分组色 / tint)
width: auto;
height: auto;
background: transparent;
color: var(--appicon-ink, currentColor);
font-size: 26px;
}
+6
View File
@@ -6,6 +6,12 @@
@use "utils/utils"; @use "utils/utils";
@use "utils/bsch_title"; @use "utils/bsch_title";
// AppIcon 资产库(CSS mask 版,data-uri 自包含):
// 拷贝自 D:\SoftProject\yunhai\YschAppIcons\appicon.mask.css(资产库 build_mask_css.py 产物)。
// 同步方式:资产库重跑构建后整文件覆盖本处,避免 P1 静态应用部署顺序依赖与跨域问题。
// 注意:必须 @use 在 componets/* 之前——scene 档(.appicon-tile 等)需覆盖本文件的 .appicon 基类字号。
@use "appicon.mask.css";
@use "componets/status"; @use "componets/status";
@use "componets/small_table"; @use "componets/small_table";
@use "componets/my_table"; @use "componets/my_table";
+36
View File
@@ -0,0 +1,36 @@
<template>
<i class="appicon" :class="classes" :style="styles" aria-hidden="true"></i>
</template>
<script lang="ts" setup>
/**
* 矢量应用图标(AppIcon 资产库,CSS mask 版)。
* - name:资产库图标键(appicon-<名>,形状来自全局 appicon.mask.css,data-uri 自包含)
* - scene:tile 方块底 / circle 正圆底 / plain 无底线性(三档端侧 token,见 _app_container.scss)
* - tint:人工配置色(application_info.icon_color)——tile/circle 覆盖底色、plain 覆盖图标色;空=场景默认
* 渲染契约(方案 §4):图标资源只管形状,容器管底色与图标色。
*/
import { computed } from "vue";
const props = withDefaults(defineProps<{
name?: string;
scene?: "tile" | "circle" | "plain";
tint?: string;
}>(), {
name: "",
scene: "tile",
tint: "",
});
const classes = computed(() => [
`appicon-${props.scene}`,
...(props.name ? [`appicon-${props.name}`] : []),
]);
const styles = computed<Record<string, string> | undefined>(() => {
if (!props.tint) return undefined;
return props.scene === "plain"
? { color: props.tint }
: { backgroundColor: props.tint };
});
</script>
+15 -1
View File
@@ -13,7 +13,9 @@
<vuescroll> <vuescroll>
<div class="app-search-list"> <div class="app-search-list">
<div v-for="(item, index) in appList" class="app-container"> <div v-for="(item, index) in appList" class="app-container">
<img :src="$utils.appIconPath(item.icon)" /> <AppIcon v-if="vectorIconOf(item)" :name="vectorIconOf(item)" scene="tile"
:tint="item.iconColor" />
<img v-else :src="$utils.appIconPath(item.icon)" />
<div class="app-name">{{ item.name }}</div> <div class="app-name">{{ item.name }}</div>
</div> </div>
</div> </div>
@@ -26,10 +28,22 @@
import { useDefaultStore } from "@/store"; import { useDefaultStore } from "@/store";
import { ref, inject, computed, onMounted, watch } from "vue"; import { ref, inject, computed, onMounted, watch } from "vue";
import vuescroll from "vuescroll"; import vuescroll from "vuescroll";
import AppIcon from "@/components/AppIcon.vue";
import { appiconKeyOf } from "@/constants/appIconRules";
import useCurrentInstance from "@/utils/hook/useCurrentInstance"; import useCurrentInstance from "@/utils/hook/useCurrentInstance";
const { $utils } = useCurrentInstance(); const { $utils } = useCurrentInstance();
/**
* 矢量渲染判定(方案 §5.4 回退链,与 AppList.vue / index.vue 同口径):
* icon_key 命中 → 矢量;有位图 URL → 位图(存量零回归);URL 空且关键词命中 → 矢量;否则默认图。
*/
function vectorIconOf(item: any): string {
if (item?.iconKey) return item.iconKey;
if (item?.icon) return "";
return appiconKeyOf(item?.name || "");
}
const refInput = ref(); const refInput = ref();
const searchName = ref(); const searchName = ref();
+43
View File
@@ -0,0 +1,43 @@
/**
* 应用名称关键词 → AppIcon 资产库图标键映射(D4:两端各持一份,关键词列与 MobilePortal 完全一致)。
*
* 定位(方案 §5.4 / E4):只做兜底、不做主路径——仅在「icon_key 未配置 且 无 icon URL」时生效
* (替代默认占位图);有 URL 的存量应用恒定位图渲染,零回归。
*
* 值列语义:appicon 图标键(如 tongzhi);空串 = 该关键词暂无对应图标(dormant,资产库补图后填值激活)。
* 一致性断言:YschAppIcons/verify_map.mjs 深比较两端关键词列(含顺序);值列两端允许不同
* (MobilePortal 用 wd-icon 名,本表用 appicon 键)。
*/
export const APP_ICON_RULES: [string, string][] = [
['请假', ''],
['课程', ''],
['课表', ''],
['通知', ''],
['公告', ''],
['报修', ''],
['文印', ''],
['打印', ''],
['公文', ''],
['作业', ''],
['班级', ''],
['家校', ''],
['会议', ''],
['场馆', ''],
['申购', ''],
['申领', ''],
['物品', ''],
['通讯录', ''],
['邮箱', ''],
['工资', ''],
['财务', ''],
['成绩', ''],
]
/** 名称命中关键词时返回对应 appicon 图标键;无命中/值为空返回 ''(不渲染矢量,走原回退)。 */
export function appiconKeyOf(name: string): string {
if (!name) return ''
for (const [kw, key] of APP_ICON_RULES) {
if (name.includes(kw)) return key
}
return ''
}
+19 -5
View File
@@ -30,7 +30,9 @@
]" ]"
> >
<a @click="linkPage(citem.webAddr, citem)"> <a @click="linkPage(citem.webAddr, citem)">
<img :src="$utils.appIconPath(citem.icon)" /> <AppIcon v-if="vectorIconOf(citem)" :name="vectorIconOf(citem)" scene="tile"
:tint="citem.iconColor" />
<img v-else :src="$utils.appIconPath(citem.icon)" />
<div class="app-name">{{ citem.name }}</div> <div class="app-name">{{ citem.name }}</div>
</a> </a>
</ContextMenu> </ContextMenu>
@@ -60,10 +62,10 @@
]" ]"
> >
<a @click="linkPage(item.webAddr, item)"> <a @click="linkPage(item.webAddr, item)">
<img <AppIcon v-if="vectorIconOf(item)" :name="vectorIconOf(item)" scene="tile"
:src="requireImg('defappicon.png')" :tint="item.iconColor" />
v-real-img="$utils.appIconPath(item.icon)" <img v-else :src="requireImg('defappicon.png')"
/> v-real-img="$utils.appIconPath(item.icon)" />
<div class="app-name">{{ item.name }}</div> <div class="app-name">{{ item.name }}</div>
</a> </a>
</ContextMenu> </ContextMenu>
@@ -79,6 +81,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import ContextMenu from "@/components/ContextMenu.vue"; import ContextMenu from "@/components/ContextMenu.vue";
import AppIcon from "@/components/AppIcon.vue";
import vuescroll from "vuescroll"; import vuescroll from "vuescroll";
import { ref, onMounted, onUnmounted, computed, watch } from "vue"; import { ref, onMounted, onUnmounted, computed, watch } from "vue";
import BaseApi from "@/api/BaseApi"; import BaseApi from "@/api/BaseApi";
@@ -92,6 +95,17 @@ import UserSettingApi from "@/api/UserSettingApi";
import { Message } from "view-ui-plus"; import { Message } from "view-ui-plus";
import { bschLinkPage } from "@/utils/bschLinkPage"; import { bschLinkPage } from "@/utils/bschLinkPage";
import useCurrentInstance from "@/utils/hook/useCurrentInstance"; import useCurrentInstance from "@/utils/hook/useCurrentInstance";
import { appiconKeyOf } from "@/constants/appIconRules";
/**
* 矢量渲染判定(方案 §5.4 回退链,与 index.vue / SearchPanel.vue 同口径):
* icon_key 命中 → 矢量;有位图 URL → 位图(存量零回归);URL 空且关键词命中 → 矢量;否则默认图。
*/
function vectorIconOf(app: any): string {
if (app?.iconKey) return app.iconKey;
if (app?.icon) return "";
return appiconKeyOf(app?.name || "");
}
// 仅在内容溢出时显示原生滚动条,避免空内容时也常驻滚动条 // 仅在内容溢出时显示原生滚动条,避免空内容时也常驻滚动条
const scrollOps = { const scrollOps = {
+17 -1
View File
@@ -21,7 +21,11 @@
]" ]"
> >
<a @click="openLinkPage(item)"> <a @click="openLinkPage(item)">
<img v-if="item.type == 1" :src="$utils.appIconPath(item.icon)" /> <template v-if="item.type == 1">
<AppIcon v-if="vectorIconOf(item)" :name="vectorIconOf(item)" scene="tile"
:tint="item.iconColor" />
<img v-else :src="$utils.appIconPath(item.icon)" />
</template>
<img <img
v-else-if="item.type == 2" v-else-if="item.type == 2"
:src="$conf.fileHost + '/image/lightapp.default.png'" :src="$conf.fileHost + '/image/lightapp.default.png'"
@@ -135,6 +139,8 @@
<script lang="ts" setup> <script lang="ts" setup>
import ContextMenu from "@/components/ContextMenu.vue"; import ContextMenu from "@/components/ContextMenu.vue";
import AppIcon from "@/components/AppIcon.vue";
import { appiconKeyOf } from "@/constants/appIconRules";
import { ref, reactive, onMounted, onUnmounted, computed, watch, watchEffect, nextTick } from "vue"; import { ref, reactive, onMounted, onUnmounted, computed, watch, watchEffect, nextTick } from "vue";
import BaseApi from "@/api/BaseApi"; import BaseApi from "@/api/BaseApi";
// @ts-ignore // @ts-ignore
@@ -166,6 +172,16 @@ const store = useDefaultStore();
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
/**
* 矢量渲染判定(方案 §5.4 回退链,与 AppList.vue / SearchPanel.vue 同口径):
* icon_key 命中 → 矢量;有位图 URL → 位图(存量零回归);URL 空且关键词命中 → 矢量;否则默认图。
*/
function vectorIconOf(item: any): string {
if (item?.iconKey) return item.iconKey;
if (item?.icon) return "";
return appiconKeyOf(item?.name || "");
}
const refApplist = ref(); const refApplist = ref();
const isDragShow = ref(false), const isDragShow = ref(false),
isDesltopAppDefault = ref(false), isDesltopAppDefault = ref(false),