feat(appicon): 应用图标 AppIcon 矢量选择器接入(YschAppIcons 资产库 10-04 轮)

- src/assets/appicon-icons.json:资产库图标清单
- src/components/AppIconPicker.vue:矢量图标+配色选择器(YschAppIcons mask 资产)
- AppForm.vue:平台模式 Pc 应用图标换 AppIconPicker(学校模式保留位图上传,学校覆盖线只收 pcIcon);App 位图图标移入高级折叠区(遗留通道,存量 pc_icon/app_icon 链路保留,新应用优先矢量图标键)
- AddAppContainer/EditAppContainer:配套接线
依据:根目录《AppIcon资产库落地-执行报告-20261004.md》P2 管理端图标选择器
This commit is contained in:
chenxing
2026-10-06 20:16:21 +08:00
parent efd55a3a75
commit b1dba84c49
5 changed files with 447 additions and 25 deletions
+23
View File
@@ -0,0 +1,23 @@
{
"prefix": "appicon",
"info": {
"name": "AppIcon",
"total": 2,
"author": "ysch front-end team",
"license": {
"title": "Remix Icon (Apache-2.0) 等,见 meta.json source",
"spdx": "Apache-2.0"
},
"height": 1024
},
"icons": {
"tongzhi": {
"body": "<path 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\" ></path>",
"cn": "通知"
},
"wenjuan": {
"body": "<path 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\" ></path>",
"cn": "问卷"
}
}
}
+315
View File
@@ -0,0 +1,315 @@
<template>
<div class="appicon-picker">
<!-- 当前选中态 / 清除 -->
<div class="picker-head">
<div class="picked" v-if="modelValue">
<span class="picked-glyph scene-tile" v-html="svgOf(modelValue)"></span>
<span class="picked-info">
<span class="picked-cn">{{ cnOf(modelValue) }}</span>
<span class="picked-key">{{ modelValue }}</span>
</span>
<Icon type="ios-close" class="picked-clear" title="清除(回到位图/默认回退链)" @click.stop="clearPick" />
</div>
<Button v-else size="small" icon="md-add" @click="open = true">选择矢量图标</Button>
<Button v-if="modelValue" size="small" class="ml-8" @click="open = true">更换</Button>
</div>
<!-- 着色:不指定 = 端侧默认(PC 白色 / App 分组色) -->
<div class="color-row" v-if="modelValue">
<span class="color-label">着色:</span>
<Tooltip content="空 = 跟随端侧场景默认(PC 白图标、App 分组色),一般无需指定" placement="top" transfer>
<span class="help-icon">?</span>
</Tooltip>
<div class="swatches">
<div class="swatch swatch-none" :class="{ active: !color }" title="端侧默认" @click="pickColor('')">无</div>
<div v-for="c in PRESET_COLORS" :key="c" class="swatch" :class="{ active: color === c }"
:style="{ background: c }" :title="c" @click="pickColor(c)"></div>
</div>
</div>
<Modal v-model="open" title="选择矢量图标(AppIcon 资产库)" width="640" footer-hide transfer :mask-closable="true">
<div class="picker-body">
<Input v-model="keyword" prefix="ios-search" placeholder="搜索中文名或图标名" clearable />
<div class="picker-grid">
<div v-for="item in filtered" :key="item.key" class="picker-cell"
:class="{ active: item.key === modelValue }" @click="pick(item.key)">
<span class="scene-tile" v-html="svgOf(item.key)"></span>
<div class="cell-cn">{{ item.cn }}</div>
<div class="cell-key">{{ item.key }}</div>
</div>
<div class="picker-empty" v-if="!filtered.length">无匹配图标(清单未拉取成功时仅剩内置副本)</div>
</div>
<div class="picker-tip">
三场景同形:方块底(PC 应用卡)/ 圆底(App 首页)/ 线性(App 列表)——底色由各端场景决定,此处仅选形状。
</div>
</div>
</Modal>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import builtinManifest from "@/assets/appicon-icons.json";
import useCurrentInstance from "@/utils/hook/useCurrentInstance";
/**
* 矢量图标选择器(AppIcon 资产库)。
* 清单来源:可配置 manifestUrl(默认线上 /static/appicon/icons.json),拉取失败回落本地内置副本
* (P1 静态应用未部署时管理端依然可用;副本与资产库 icons.json 同步,见资产库 build_dist)。
* 预览直接内联 svg body(不依赖字体加载),tile/circle/plain 三场景同形。
*/
const props = withDefaults(defineProps<{
/** 矢量图标键(AppIcon 图标名),空 = 不使用矢量图标 */
modelValue?: string;
/** 着色 CSS 色值,空 = 端侧默认 */
color?: string;
/** 清单地址(默认平台静态通道) */
manifestUrl?: string;
}>(), {
modelValue: '',
color: '',
});
const emit = defineEmits(['update:modelValue', 'update:color']);
const { $conf } = useCurrentInstance();
interface IconEntry { key: string; cn: string; body: string }
const open = ref(false);
const keyword = ref('');
const remoteIcons = ref<IconEntry[] | null>(null);
const PRESET_COLORS = ['#366ef4', '#3fa45b', '#f2994a', '#7c5cc4', '#17b8a6', '#ff5757'];
const builtinIcons: IconEntry[] = Object.entries((builtinManifest as any).icons || {}).map(
([key, v]: [string, any]) => ({ key, cn: v?.cn || key, body: v?.body || '' })
);
const icons = computed<IconEntry[]>(() => remoteIcons.value ?? builtinIcons);
const filtered = computed(() => {
const kw = keyword.value.trim().toLowerCase();
if (!kw) return icons.value;
return icons.value.filter(x => x.cn.includes(kw) || x.key.includes(kw));
});
function cnOf(key: string): string {
return icons.value.find(x => x.key === key)?.cn || key;
}
function bodyOf(key: string): string {
return icons.value.find(x => x.key === key)?.body || '';
}
function svgOf(key: string): string {
const body = bodyOf(key);
if (!body) return '';
return `<svg viewBox="0 0 1024 1024" fill="currentColor" xmlns="http://www.w3.org/2000/svg" style="width:1em;height:1em;display:block">${body}</svg>`;
}
function pick(key: string) {
emit('update:modelValue', key);
open.value = false;
}
function clearPick() {
emit('update:modelValue', '');
emit('update:color', '');
}
function pickColor(c: string) {
emit('update:color', c);
}
onMounted(async () => {
// 线上清单优先(P1 部署后自动生效);失败静默回落内置副本
try {
const url = props.manifestUrl || (($conf?.baseHost || '') + '/static/appicon/icons.json');
const res = await fetch(url, { credentials: 'omit' });
if (!res.ok) return;
const data = await res.json();
const list = Object.entries(data?.icons || {}).map(
([key, v]: [string, any]) => ({ key, cn: v?.cn || key, body: v?.body || '' })
);
if (list.length) remoteIcons.value = list;
} catch {
// 回落内置副本,不提示(本地开发 baseHost 指向本地时必然 404)
}
});
</script>
<style lang="scss" scoped>
.appicon-picker {
width: 100%;
}
.picker-head {
display: flex;
align-items: center;
gap: 8px;
}
.picked {
display: flex;
align-items: center;
gap: 10px;
padding: 4px 10px 4px 4px;
border: 1px solid #dcdee2;
border-radius: 6px;
.picked-cn {
font-size: 13px;
color: #515a6e;
}
.picked-key {
font-size: 11px;
color: #999;
font-family: Consolas, monospace;
margin-left: 6px;
}
.picked-clear {
cursor: pointer;
color: #999;
font-size: 18px;
&:hover {
color: #ed4014;
}
}
}
.picked-info {
display: flex;
align-items: baseline;
}
.color-row {
display: flex;
align-items: center;
margin-top: 10px;
.color-label {
font-size: 13px;
color: #515a6e;
}
.help-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
border: 1px solid #c5c8ce;
border-radius: 50%;
font-size: 11px;
color: #808695;
cursor: help;
margin-right: 8px;
}
}
.swatches {
display: flex;
gap: 8px;
}
.swatch {
width: 20px;
height: 20px;
border-radius: 50%;
cursor: pointer;
border: 2px solid transparent;
box-sizing: border-box;
&.active {
border-color: #577afb;
box-shadow: 0 0 0 1px #577afb;
}
&.swatch-none {
background: #fff;
border: 1px dashed #c5c8ce;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
color: #808695;
&.active {
border-style: solid;
}
}
}
.picker-body {
.picker-grid {
margin-top: 12px;
max-height: 380px;
overflow-y: auto;
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 10px;
}
.picker-cell {
text-align: center;
padding: 10px 6px;
border: 1px solid #e8eaec;
border-radius: 6px;
cursor: pointer;
transition: border-color 0.15s, box-shadow 0.15s;
&:hover {
border-color: #577afb;
}
&.active {
border-color: #577afb;
box-shadow: 0 0 0 1px #577afb;
}
.cell-cn {
font-size: 13px;
color: #515a6e;
margin-top: 8px;
}
.cell-key {
font-size: 11px;
color: #aaa;
font-family: Consolas, monospace;
}
}
.picker-empty {
grid-column: 1 / -1;
color: #bbb;
text-align: center;
padding: 24px 0;
}
.picker-tip {
margin-top: 12px;
font-size: 12px;
color: #808695;
line-height: 1.6;
}
}
/* 选中态/网格预览的方块场景(与端侧 tile token 同构,仅管理端演示用) */
.scene-tile {
--appicon-bg: #366ef4;
display: inline-flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 18%;
background: var(--appicon-bg);
color: #fff;
font-size: 24px;
}
</style>
+3 -1
View File
@@ -22,7 +22,7 @@ let onSuccessCallback: (() => void) | null = null;
// useForm 托管提交 // useForm 托管提交
const { form, loading: submitting, send: sendForm, onSuccess, onError } = useForm( const { form, loading: submitting, send: sendForm, onSuccess, onError } = useForm(
(dto) => Apis.AppSettings.addApp({ data: dto }), (dto) => Apis.AppSettings.addApp({ data: dto }),
{ initialForm: { name: '', categoryId: undefined, desc: '', appCode: '', appNature: 'BUSINESS', clients: [], pcUrl: '', pcIcon: '', appUrl: '', appIcon: '', officialAccountUrl: '', dingtalkUrl: '' }, resetAfterSubmiting: true } { initialForm: { name: '', categoryId: undefined, desc: '', appCode: '', appNature: 'BUSINESS', clients: [], pcUrl: '', pcIcon: '', appUrl: '', appIcon: '', iconKey: '', iconColor: '', officialAccountUrl: '', dingtalkUrl: '' }, resetAfterSubmiting: true }
); );
onSuccess(() => { onSuccess(() => {
@@ -57,6 +57,8 @@ async function handleSubmit(formData: Record<string, any>) {
pcIcon: formData.pcIcon, pcIcon: formData.pcIcon,
appUrl: formData.appUrl, appUrl: formData.appUrl,
appIcon: formData.appIcon, appIcon: formData.appIcon,
iconKey: formData.iconKey,
iconColor: formData.iconColor,
officialAccountUrl: formData.officialAccountUrl, officialAccountUrl: formData.officialAccountUrl,
dingtalkUrl: formData.dingtalkUrl, dingtalkUrl: formData.dingtalkUrl,
}; };
+92 -13
View File
@@ -86,14 +86,14 @@
</Col> </Col>
</Row> </Row>
<!-- 第五行:Pc应用地址 / Pc应用图标(基石应用整行隐藏——不进门户网格,图标无消费方;学校模式仅保留图标可改) --> <!-- 第五行:Pc应用地址 / 应用图标(平台模式=AppIcon 矢量选择器;学校模式保留位图上传——学校覆盖线只收 pcIcon) -->
<Row v-if="!natureBase"> <Row v-if="!natureBase">
<Col span="12" v-if="!schoolMode"> <Col span="12" v-if="!schoolMode">
<FormItem label="Pc应用地址:"> <FormItem label="Pc应用地址:">
<Input v-model="form.pcUrl" placeholder="请输入Pc应用地址" /> <Input v-model="form.pcUrl" placeholder="请输入Pc应用地址" />
</FormItem> </FormItem>
</Col> </Col>
<Col span="12"> <Col span="12" v-if="schoolMode">
<FormItem label="Pc应用图标:"> <FormItem label="Pc应用图标:">
<Upload :on-success="(res: any) => onUploadSuccess(res, 'pcIcon')" :show-upload-list="false" <Upload :on-success="(res: any) => onUploadSuccess(res, 'pcIcon')" :show-upload-list="false"
:action="uploadUrl" :data="{ type: 'icon' }" accept=".svg,.png,.jpg,.jpeg" :action="uploadUrl" :data="{ type: 'icon' }" accept=".svg,.png,.jpg,.jpeg"
@@ -109,30 +109,68 @@
</Upload> </Upload>
</FormItem> </FormItem>
</Col> </Col>
<Col span="12" v-else>
<FormItem label="应用图标:">
<AppIconPicker v-model="form.iconKey" v-model:color="form.iconColor" />
</FormItem>
</Col>
</Row> </Row>
<!-- 第六行:App接口地址 / App应用图标(学校模式/基石应用隐藏) --> <!-- 第六行:App接口地址(学校模式/基石应用隐藏;App位图图标移入下方高级折叠区) -->
<Row v-if="!schoolMode && !natureBase"> <Row v-if="!schoolMode && !natureBase">
<Col span="12"> <Col span="12">
<FormItem label="App接口地址:"> <FormItem label="App接口地址:">
<Input v-model="form.appUrl" placeholder="请输入App接口地址" /> <Input v-model="form.appUrl" placeholder="请输入App接口地址" />
</FormItem> </FormItem>
</Col> </Col>
<Col span="12"> </Row>
<FormItem label="App应用图标:">
<Upload :on-success="(res: any) => onUploadSuccess(res, 'appIcon')" :show-upload-list="false" <!-- 位图上传(高级 · 遗留通道):存量 pc_icon/app_icon 位图链路保留,新应用优先矢量图标键 -->
<Row v-if="!schoolMode && !natureBase">
<Col span="24">
<Collapse>
<Panel name="bitmap">
上传位图图标(高级 · 遗留通道)
<template #content>
<div class="bitmap-upload-row">
<div class="bitmap-upload-item">
<div class="bitmap-label">Pc应用图标:</div>
<Upload :on-success="(res: any) => onUploadSuccess(res, 'pcIcon')"
:before-upload="beforeBitmapUpload" :show-upload-list="false"
:action="uploadUrl" :data="{ type: 'icon' }" accept=".svg,.png,.jpg,.jpeg"
:with-credentials="true" :headers="{ yschapirequest: 1 }">
<div v-if="!form.pcIcon" class="icon-upload-wrap">
<SvgIcon name="upload-add" color="#646566" style="width: 20px; height: 20px" />
</div>
<div class="icon-preview-wrap" v-else>
<SvgIcon @click.stop="form.pcIcon = null" class="cloase-btn"
name="widget-close" size="18" color="#fff" />
<img :src="previewUrl(form.pcIcon)" alt="" />
</div>
</Upload>
<div class="bitmap-hint">矢量图标(上方)优先生效;此位图为存量回退。</div>
</div>
<div class="bitmap-upload-item">
<div class="bitmap-label">App应用图标:</div>
<Upload :on-success="(res: any) => onUploadSuccess(res, 'appIcon')"
:before-upload="beforeBitmapUpload" :show-upload-list="false"
:action="uploadUrl" :data="{ type: 'icon' }" accept=".svg,.png,.jpg,.jpeg" :action="uploadUrl" :data="{ type: 'icon' }" accept=".svg,.png,.jpg,.jpeg"
:with-credentials="true" :headers="{ yschapirequest: 1 }"> :with-credentials="true" :headers="{ yschapirequest: 1 }">
<div v-if="!form.appIcon" class="icon-upload-wrap"> <div v-if="!form.appIcon" class="icon-upload-wrap">
<SvgIcon name="upload-add" color="#646566" style="width: 20px; height: 20px" /> <SvgIcon name="upload-add" color="#646566" style="width: 20px; height: 20px" />
</div> </div>
<div class="icon-preview-wrap" v-else> <div class="icon-preview-wrap" v-else>
<SvgIcon @click.stop="form.appIcon = null" class="cloase-btn" name="widget-close" size="18" <SvgIcon @click.stop="form.appIcon = null" class="cloase-btn"
color="#fff" /> name="widget-close" size="18" color="#fff" />
<img :src="previewUrl(form.appIcon)" alt="" /> <img :src="previewUrl(form.appIcon)" alt="" />
</div> </div>
</Upload> </Upload>
</FormItem> <div class="bitmap-hint">存量移动端位图;矢量图标优先生效。</div>
</div>
</div>
</template>
</Panel>
</Collapse>
</Col> </Col>
</Row> </Row>
@@ -160,6 +198,7 @@
<script setup lang="ts"> <script setup lang="ts">
import SvgIcon from "@/components/SvgIcon.vue"; import SvgIcon from "@/components/SvgIcon.vue";
import AppIconPicker from "@/components/AppIconPicker.vue";
import { computed, reactive, watch } from "vue"; import { computed, reactive, watch } from "vue";
import useCurrentInstance from "@/utils/hook/useCurrentInstance"; import useCurrentInstance from "@/utils/hook/useCurrentInstance";
@@ -205,6 +244,9 @@ const defaultForm = (): Record<string, any> => ({
pcIcon: '', pcIcon: '',
appUrl: '', appUrl: '',
appIcon: '', appIcon: '',
// 矢量图标(AppIcon 资产库):iconKey 空 = 不使用矢量,端侧走 关键词→位图→首字 原回退链
iconKey: '',
iconColor: '',
officialAccountUrl: '', officialAccountUrl: '',
dingtalkUrl: '', dingtalkUrl: '',
}); });
@@ -238,6 +280,17 @@ function onUploadSuccess(response: any, field: 'pcIcon' | 'appIcon') {
} }
} }
/* 位图上传前置校验:≤64KB(与后端 P4 校验同口径;svg 另由后端消毒) */
const BITMAP_MAX_BYTES = 64 * 1024;
function beforeBitmapUpload(file: File) {
if (file.size > BITMAP_MAX_BYTES) {
Message.error(`图标文件 ${(file.size / 1024).toFixed(0)}KB 超过 64KB 上限,请压缩后上传`);
return false;
}
return true;
}
/* ---------- 前端类型选项 ---------- */ /* ---------- 前端类型选项 ---------- */
// value 对齐后端平台枚举 com.yunhai.platform.utils.enums.ClientEnum(位掩码 key): // value 对齐后端平台枚举 com.yunhai.platform.utils.enums.ClientEnum(位掩码 key):
// 1=Pc浏览器 2=移动App 8=微信公众号 32=钉钉;公众号/钉钉地址见表单第七行 // 1=Pc浏览器 2=移动App 8=微信公众号 32=钉钉;公众号/钉钉地址见表单第七行
@@ -390,6 +443,27 @@ function onVisibleChange(val: boolean) {
} }
} }
/* 位图上传(高级折叠区):并排两枚上传位 */
.bitmap-upload-row {
display: flex;
gap: 32px;
padding: 8px 4px;
}
.bitmap-upload-item {
.bitmap-label {
font-size: 13px;
color: #515a6e;
margin-bottom: 8px;
}
.bitmap-hint {
margin-top: 6px;
font-size: 12px;
color: #999;
}
}
/* 应用性质说明文字 */ /* 应用性质说明文字 */
.nature-hint { .nature-hint {
margin-left: 8px; margin-left: 8px;
@@ -397,16 +471,21 @@ function onVisibleChange(val: boolean) {
font-size: 12px; font-size: 12px;
} }
/* 表单帮助图标(Tooltip 触发器):默认浅灰,悬停点亮 */ /* 表单帮助图标(Tooltip 触发器):静默态用中性灰阶令牌,交互态才升主色(含淡底),
一律不写死色号——切档位(blue/ocean/ink/violet/teal)自动跟随。
括号内兜底值 = 默认档 blue,仅防通道 CSS 不可达时图标失色(接入后由通道令牌接管)。 */
.help-icon { .help-icon {
margin-left: 6px; margin-left: 6px;
color: #c5c8ce; padding: 1px;
border-radius: 50%;
color: var(--text-3, #98a1b2);
font-size: 16px; font-size: 16px;
cursor: pointer; cursor: pointer;
transition: color 0.2s; transition: color 0.15s, background-color 0.15s;
&:hover { &:hover {
color: #2d8cf0; color: var(--brand, #577afb);
background-color: var(--brand-soft, #eef2ff);
} }
} }
</style> </style>
+5 -2
View File
@@ -34,7 +34,7 @@ const { form, loading: submitting, send: sendForm, onSuccess, onError } = useFor
data: dto, data: dto,
params: props.schoolId != null ? { schoolId: props.schoolId } : undefined, params: props.schoolId != null ? { schoolId: props.schoolId } : undefined,
}), }),
{ initialForm: { id: null, name: '', categoryId: undefined, desc: '', appCode: '', appNature: 'BUSINESS', clients: [], pcUrl: '', pcIcon: '', appUrl: '', appIcon: '', officialAccountUrl: '', dingtalkUrl: '' }, resetAfterSubmiting: true } { initialForm: { id: null, name: '', categoryId: undefined, desc: '', appCode: '', appNature: 'BUSINESS', clients: [], pcUrl: '', pcIcon: '', appUrl: '', appIcon: '', iconKey: '', iconColor: '', officialAccountUrl: '', dingtalkUrl: '' }, resetAfterSubmiting: true }
); );
onSuccess(() => { onSuccess(() => {
@@ -68,7 +68,8 @@ async function handleSubmit(formData: Record<string, any>) {
if (!currentId.value) return; if (!currentId.value) return;
if (isSchoolMode) { if (isSchoolMode) {
// 学校模式:仅 名称/描述/Pc图标 三字段写学校覆盖表(后端 editAppForSchool 只读这三个); // 学校模式:仅 名称/描述/Pc图标 三字段写学校覆盖表(后端 editAppForSchool 只读这三个);
// categoryId/appCode/appNature 原样回传仅为满足平台级 Update 校验(后端不落平台表),缺失会 400 // categoryId/appCode/appNature 原样回传仅为满足平台级 Update 校验(后端不落平台表),缺失会 400;
// iconKey/iconColor 不送键(后端 null=不改——学校线不动平台矢量配置)
form.value = { form.value = {
id: currentId.value, id: currentId.value,
name: formData.name, name: formData.name,
@@ -91,6 +92,8 @@ async function handleSubmit(formData: Record<string, any>) {
pcIcon: formData.pcIcon, pcIcon: formData.pcIcon,
appUrl: formData.appUrl, appUrl: formData.appUrl,
appIcon: formData.appIcon, appIcon: formData.appIcon,
iconKey: formData.iconKey,
iconColor: formData.iconColor,
officialAccountUrl: formData.officialAccountUrl, officialAccountUrl: formData.officialAccountUrl,
dingtalkUrl: formData.dingtalkUrl, dingtalkUrl: formData.dingtalkUrl,
}; };