feat(web-admin): add OaAppIconPicker for ep palette and upload.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zlwuandCursor committed 2026-08-06 13:11:16 +08:00
1 parent e4279dc953
commit 0cbfa7bb91
2 files changed
+347 -5

No files matched your search

@@ -9,8 +9,52 @@
<script setup lang="ts">
import { computed, type Component } from 'vue'
import * as EpIcons from '@element-plus/icons-vue'
import { OA_APP_DEFAULT_EP_ICON, resolveOaAppIcon } from '@/utils/oa-app-icons'
import {
Bell,
Briefcase,
Calendar,
Checked,
Clock,
CollectionTag,
CreditCard,
Document,
Location,
Message,
Money,
Notebook,
OfficeBuilding,
Present,
Setting,
Stamp,
Tickets,
User,
Van,
Wallet
} from '@element-plus/icons-vue'
import { resolveOaAppIcon, type OaAppEpIconName } from '@/utils/oa-app-icons'
const epMap: Record<OaAppEpIconName, Component> = {
Document,
Calendar,
Clock,
Wallet,
User,
Tickets,
Briefcase,
Money,
Van,
OfficeBuilding,
Message,
Setting,
CollectionTag,
Checked,
Present,
Stamp,
CreditCard,
Notebook,
Bell,
Location
}
const props = withDefaults(
defineProps<{
@@ -24,9 +68,9 @@
const resolved = computed(() => resolveOaAppIcon(props.iconUrl))
const epComponent = computed<Component>(() => {
if (resolved.value.kind !== 'ep') return (EpIcons as any)[OA_APP_DEFAULT_EP_ICON]
const name = resolved.value.icon
return (EpIcons as any)[name] || (EpIcons as any)[OA_APP_DEFAULT_EP_ICON]
if (resolved.value.kind !== 'ep') return Document
const name = resolved.value.icon as OaAppEpIconName
return epMap[name] || Document
})
const boxStyle = computed(() => {
@@ -0,0 +1,298 @@
<template>
<div class="oa-icon-picker">
<OaAppIcon :icon-url="modelValue" size="md" />
<div class="oa-icon-picker__actions">
<el-button class="btn-ghost" type="button" @click="panelOpen = !panelOpen">
<el-icon><Grid /></el-icon>
从图标库选择
</el-button>
<el-button class="btn-ghost" type="button" @click="onRecolor">
<el-icon><Brush /></el-icon>
换底色
</el-button>
<el-button class="btn-ghost" type="button" @click="onRandom">
<el-icon><Refresh /></el-icon>
随机图标+底色
</el-button>
<el-upload
:show-file-list="false"
accept="image/*"
:http-request="uploadIcon"
:disabled="uploading"
>
<el-button class="btn-ghost" :loading="uploading">
<el-icon><Upload /></el-icon>
上传图片
</el-button>
</el-upload>
<p class="oa-icon-picker__tip">
新增时随机淡色底 + EP 图标(同色系深色);可展开选图标/换底色,或上传自定义图
</p>
</div>
<div v-show="panelOpen" class="oa-icon-picker__panel">
<div class="oa-icon-picker__panel-head">
<strong>Element Plus 图标(精选)</strong>
<el-button class="btn-ghost" style="height: 24px" @click="panelOpen = false">收起</el-button>
</div>
<div class="oa-icon-picker__grid">
<button
v-for="name in OA_APP_EP_ICON_NAMES"
:key="name"
type="button"
class="oa-icon-picker__cell"
:class="{ active: currentEp.icon === name && isEp }"
@click="onPickIcon(name)"
>
<el-icon :style="{ color: currentEp.fg }"><component :is="epMap[name]" /></el-icon>
<span>{{ name }}</span>
</button>
</div>
<div class="form-label" style="margin-top: 10px; font-size: 12px; color: var(--mint-text-muted, #94a3b8)">
底色(8 组彩色,无灰色)
</div>
<div class="oa-icon-picker__colors">
<button
v-for="pair in OA_APP_ICON_COLOR_PAIRS"
:key="pair.bg"
type="button"
class="oa-icon-picker__dot"
:class="{ active: currentEp.bg === pair.bg && isEp }"
:style="{ background: pair.bg }"
:title="pair.bg"
@click="onPickBg(pair.bg)"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, type Component } from 'vue'
import type { UploadRequestOptions } from 'element-plus'
import {
Bell,
Briefcase,
Brush,
Calendar,
Checked,
Clock,
CollectionTag,
CreditCard,
Document,
Grid,
Location,
Message,
Money,
Notebook,
OfficeBuilding,
Present,
Refresh,
Setting,
Stamp,
Tickets,
Upload,
User,
Van,
Wallet
} from '@element-plus/icons-vue'
import { fetchUploadAttachment } from '@/api/business'
import OaAppIcon from '@/components/oa-approval/OaAppIcon.vue'
import {
OA_APP_EP_ICON_NAMES,
OA_APP_ICON_COLOR_PAIRS,
buildEpIconUrl,
isEpIconUrl,
parseEpIconUrl,
pickRandomOaAppIconUrl,
recolorOaAppIconUrl,
type OaAppEpIconName
} from '@/utils/oa-app-icons'
const props = defineProps<{ modelValue?: string | null }>()
const emit = defineEmits<{ 'update:modelValue': [string] }>()
const panelOpen = ref(false)
const uploading = ref(false)
const isEp = computed(() => isEpIconUrl(props.modelValue))
const currentEp = computed(() => parseEpIconUrl(props.modelValue))
const epMap: Record<OaAppEpIconName, Component> = {
Document,
Calendar,
Clock,
Wallet,
User,
Tickets,
Briefcase,
Money,
Van,
OfficeBuilding,
Message,
Setting,
CollectionTag,
Checked,
Present,
Stamp,
CreditCard,
Notebook,
Bell,
Location
}
function setUrl(v: string) {
emit('update:modelValue', v)
}
function onPickIcon(name: string) {
setUrl(buildEpIconUrl(name, currentEp.value.bg))
}
function onPickBg(bg: string) {
setUrl(recolorOaAppIconUrl(props.modelValue, bg))
}
function onRecolor() {
const list = OA_APP_ICON_COLOR_PAIRS
const cur = currentEp.value.bg
let next = list[Math.floor(Math.random() * list.length)].bg
if (list.length > 1) {
let guard = 0
while (next === cur && guard++ < 8) {
next = list[Math.floor(Math.random() * list.length)].bg
}
}
setUrl(recolorOaAppIconUrl(props.modelValue, next))
}
function onRandom() {
setUrl(pickRandomOaAppIconUrl())
}
async function uploadIcon(options: UploadRequestOptions) {
const file = options.file as File
if (!file?.type?.startsWith('image/')) {
ElMessage.warning('请上传图片文件')
return
}
uploading.value = true
try {
const fd = new FormData()
fd.append('file', file)
const res: any = await fetchUploadAttachment(fd)
const url = res?.fileUrl || res?.data?.fileUrl
if (!url) {
ElMessage.error('上传失败,未返回地址')
return
}
setUrl(url)
options.onSuccess?.(res)
} catch (e) {
options.onError?.(e as any)
} finally {
uploading.value = false
}
}
</script>
<style scoped lang="scss">
.oa-icon-picker {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: flex-start;
width: 100%;
}
.oa-icon-picker__actions {
display: flex;
flex: 1;
flex-wrap: wrap;
gap: 8px;
align-items: center;
min-width: 200px;
}
.oa-icon-picker__tip {
flex-basis: 100%;
margin: 0;
color: var(--mint-text-muted, #94a3b8);
font-size: 12px;
}
.oa-icon-picker__panel {
flex-basis: 100%;
padding: 12px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 10px;
}
.oa-icon-picker__panel-head {
display: flex;
gap: 8px;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
strong {
font-size: 13px;
}
}
.oa-icon-picker__grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(72px, 1fr));
gap: 8px;
max-height: 220px;
overflow: auto;
}
.oa-icon-picker__cell {
display: flex;
flex-direction: column;
gap: 4px;
align-items: center;
justify-content: center;
height: 56px;
padding: 4px;
color: #475569;
font-size: 10px;
background: #fff;
border: 1px solid transparent;
border-radius: 8px;
cursor: pointer;
&:hover {
color: var(--primary-dark, #059669);
border-color: #a7f3d0;
}
&.active {
color: var(--primary-dark, #059669);
background: var(--primary-bg, #ecfdf5);
border-color: var(--primary, #10b981);
}
}
.oa-icon-picker__colors {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 10px;
}
.oa-icon-picker__dot {
width: 28px;
height: 28px;
border: 2px solid transparent;
border-radius: 8px;
box-shadow: inset 0 0 0 1px rgb(15 23 42 / 6%);
cursor: pointer;
&.active {
border-color: var(--primary-dark, #059669);
box-shadow: 0 0 0 2px rgb(16 185 129 / 25%);
}
}
</style>