Merge remote-tracking branch 'origin/dev/message-oa' into feature/invoice

# Conflicts:
#	apps/web-antd/src/views/system/system-config/index.vue
This commit is contained in:
李琦
2026-07-28 08:25:45 +08:00
73 changed files with 15613 additions and 37 deletions

View File

@@ -8,11 +8,17 @@ const props = withDefaults(
multiple?: boolean;
maxCount?: number;
disabled?: boolean;
/**
* 限制可选择的文件类型(透传给 image-gallery-picker按 xk_file_type.value 过滤)
* 例如图片:[1];文件:[6];语音:[3]
*/
acceptTypes?: number[];
}>(),
{
multiple: false,
maxCount: 1,
disabled: false,
acceptTypes: () => [] as number[],
},
);
@@ -41,6 +47,7 @@ function onSelect(urls: string[]) {
v-model:open="galleryOpen"
:multiple="multiple"
:max-count="maxCount"
:accept-types="acceptTypes"
@select="onSelect"
/>
</div>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import { computed, ref, watch } from 'vue';
import { Empty, Input, Modal, Pagination, Spin, Tabs } from 'ant-design-vue';
@@ -14,10 +14,18 @@ const props = withDefaults(
open: boolean;
multiple?: boolean;
maxCount?: number;
/**
* 限制可选择的文件类型(按 xk_file_type.value 过滤)
* - 不传:显示全部类型(默认行为,保持向后兼容)
* - 传数组:只展示指定类型 tab且自动锁定到第一个匹配的类型
* 例如图片:[1];图片+视频:[1, 2];文件:[6]
*/
acceptTypes?: number[];
}>(),
{
multiple: false,
maxCount: 1,
acceptTypes: () => [] as number[],
},
);
@@ -34,6 +42,14 @@ const pageSize = ref(24);
const pageSizeOptions = ['12', '24', '48'];
const selected = ref<string[]>([]);
/** 按 acceptTypes 隔离 localStorage避免图片/视频选择器互相污染 */
const storageKey = computed(
() =>
`file-picker-type-${
props.acceptTypes?.length ? props.acceptTypes.join('-') : 'all'
}`,
);
const {
activeType,
keyword,
@@ -42,14 +58,55 @@ const {
buildListParams,
fileTypes,
typesLoading,
} = useFileGalleryFilter('file-picker-active-type');
} = useFileGalleryFilter(() => storageKey.value);
let searchTimer: ReturnType<typeof setTimeout> | null = null;
/**
* 实际渲染的 tabs按 acceptTypes 过滤)
*/
const visibleTabs = computed(() => {
if (!props.acceptTypes || props.acceptTypes.length === 0) {
return tabs.value;
}
return tabs.value.filter(
(tab) => tab.value !== -1 && props.acceptTypes.includes(Number(tab.value)),
);
});
/**
* 强制锁定到 acceptTypes 首项(打开弹窗 / 类型字典就绪时调用)
*/
function lockAcceptType() {
const arr = props.acceptTypes;
if (!arr || arr.length === 0) {
return;
}
if (activeType.value !== arr[0]) {
setActiveType(arr[0]!);
}
}
/**
* 是否允许拉列表:有 acceptTypes 时需等类型字典加载完且 visibleTabs 非空
*/
function canLoadList() {
if (typesLoading.value) {
return false;
}
if (props.acceptTypes && props.acceptTypes.length > 0) {
return visibleTabs.value.length > 0;
}
return true;
}
async function load() {
if (!canLoadList()) {
return;
}
loading.value = true;
try {
const res = await getFileGalleryList(buildListParams(page.value, pageSize.value));
const res = await getFileGalleryList(
buildListParams(page.value, pageSize.value),
);
const data = (res as any)?.data ?? res;
items.value = data?.items ?? [];
total.value = data?.total ?? 0;
@@ -58,21 +115,37 @@ async function load() {
}
}
watch(
() => props.acceptTypes,
(arr) => {
if (!arr || arr.length === 0) {
return;
}
lockAcceptType();
page.value = 1;
void load();
},
{ immediate: true },
);
let searchTimer: ReturnType<typeof setTimeout> | null = null;
watch(
() => props.open,
(val) => {
if (val) {
selected.value = [];
page.value = 1;
if (!typesLoading.value) {
void load();
}
// 打开时再次按 acceptTypes 重锁,避免沿用其它选择器的视频 tab
lockAcceptType();
void load();
}
},
);
watch(typesLoading, (val) => {
if (!val && props.open) {
lockAcceptType();
void load();
}
});
@@ -143,6 +216,11 @@ function onSearch(value: string) {
function itemIcon(item: FileGalleryItem) {
return item.type_icon || resolveTypeIcon(item.type, fileTypes.value);
}
/** 图片类型展示缩略图xk_file_type1=图片) */
function isImageItem(item: FileGalleryItem) {
return Number(item.type) === 1 || Number(item.type) === 0;
}
</script>
<template>
@@ -155,7 +233,7 @@ function itemIcon(item: FileGalleryItem) {
@cancel="handleCancel"
>
<Tabs :active-key="String(activeType)" size="small" @change="onTabChange">
<Tabs.TabPane v-for="tab in tabs" :key="String(tab.value)">
<Tabs.TabPane v-for="tab in visibleTabs" :key="String(tab.value)">
<template #tab>
<span class="tab-label">
<MIcon v-if="tab.icon" :icon="tab.icon" size="12" />
@@ -176,7 +254,7 @@ function itemIcon(item: FileGalleryItem) {
/>
</div>
<Spin :spinning="loading">
<Spin :spinning="loading || typesLoading">
<div v-if="items.length" class="gallery-grid">
<div
v-for="item in items"
@@ -185,10 +263,17 @@ function itemIcon(item: FileGalleryItem) {
:class="{ active: isSelected(item.url) }"
@click="toggleSelect(item.url)"
>
<img v-if="item.type === 0" :src="item.url" alt="" class="gallery-thumb" />
<img
v-if="isImageItem(item)"
:src="item.url"
alt=""
class="gallery-thumb"
/>
<div v-else class="gallery-file-icon">
<MIcon :icon="itemIcon(item)" size="28" />
<span class="file-name">{{ item.file_name || item.original_name || '未命名' }}</span>
<span class="file-name">{{
item.file_name || item.original_name || '未命名'
}}</span>
</div>
<div class="gallery-meta">
<div class="file-title">

View File

@@ -30,12 +30,17 @@ interface Props {
modelValue: string[];
multiple?: boolean;
maxCount?: number;
/**
* 素材库可选类型(透传 GalleryPickLink默认仅图片
*/
acceptTypes?: number[];
}
const props = withDefaults(defineProps<Props>(), {
modelValue: () => [],
multiple: true,
maxCount: 9,
acceptTypes: () => [1],
});
const emit = defineEmits<{
@@ -251,6 +256,7 @@ function onTogglePaste() {
v-if="showUploadButton"
:multiple="multiple"
:max-count="galleryRemainCount"
:accept-types="acceptTypes"
@select="onGallerySelect"
/>

View File

@@ -1,8 +1,14 @@
<script setup lang="ts">
/**
* OSS 文件上传(单文件 URL 字符串)
* 可选从素材库选择acceptTypes 过滤,如文件=6、语音=3
*/
import { computed, ref, watch } from 'vue';
import { preferences } from '@vben/preferences';
import { Button, Upload } from 'ant-design-vue';
import GalleryPickLink from '#/components/form/components/gallery-pick-link.vue';
import { Icon } from '#/components/icon';
import { uploadFile } from '#/api/core/upload';
import { uploadToOss } from '#/utils/oss-upload';
@@ -17,11 +23,17 @@ interface FileItem {
interface Props {
modelValue?: string;
maxCount?: number;
/** 素材库类型过滤,如 [6] 文件、[3] 语音 */
acceptTypes?: number[];
/** input accept如 .pdf,.doc 或 audio/* */
accept?: string;
}
const props = withDefaults(defineProps<Props>(), {
modelValue: '',
maxCount: 1,
acceptTypes: () => [6],
accept: '.pdf,.doc,.docx,.zip,.rar,.txt,application/pdf,audio/*,*/*',
});
const emit = defineEmits<{
@@ -131,26 +143,50 @@ function handleRemove() {
return true;
}
function onGallerySelect(urls: string[]) {
const url = urls[0] || '';
if (!url) return;
syncFileListFromValue(url);
updateModelValue();
}
const showUploadButton = computed(
() => fileList.value.filter((item) => item.status !== 'error').length === 0,
);
</script>
<template>
<Upload
:file-list="fileList"
:before-upload="beforeUpload"
:custom-request="customRequest"
:max-count="maxCount"
list-type="text"
accept=".pdf,.jpg,.jpeg,.png,.doc,.docx,image/*,application/pdf"
@remove="handleRemove"
>
<Button v-if="showUploadButton">
上传文件
<template #icon>
<Icon icon="ant-design:cloud-upload-outlined" />
</template>
</Button>
</Upload>
<div class="upload-oss-file-wrap">
<Upload
:file-list="fileList"
:before-upload="beforeUpload"
:custom-request="customRequest"
:max-count="maxCount"
list-type="text"
:accept="accept"
@remove="handleRemove"
>
<Button v-if="showUploadButton">
上传文件
<template #icon>
<Icon icon="ant-design:cloud-upload-outlined" />
</template>
</Button>
</Upload>
<GalleryPickLink
v-if="showUploadButton"
:max-count="1"
:accept-types="acceptTypes"
@select="onGallerySelect"
/>
</div>
</template>
<style scoped>
.upload-oss-file-wrap {
display: flex;
flex-direction: column;
gap: 8px;
align-items: flex-start;
}
</style>

View File

@@ -0,0 +1,170 @@
<script setup lang="ts">
/**
* 通用 Tag 选择器(带头像)
*
* - 基于 antd Selectmultiple 模式默认开启,单选传 multiple=false
* - 已选项以 Tag 形式展示头像+名字:
* - 有 avatar 用图片头像
* - 无 avatar 用文本头像(取 label 首字符)
* - 下拉选项同样展示头像+名字
* - 选项数据options: { value, label, avatar? }[]
*/
import { computed } from 'vue';
import { useVModel } from '@vueuse/core';
import { Avatar, Select, Tag } from 'ant-design-vue';
defineOptions({
name: 'UserTagSelect',
inheritAttrs: false,
});
/** 单个选项结构 */
export interface UserTagOption {
value: string | number;
label: string;
avatar?: string;
}
const props = withDefaults(
defineProps<{
/** 已选值数组(多选)或单值(单选时仍用数组包装) */
modelValue?: (string | number)[];
/** 选项列表 */
options: UserTagOption[];
/** 是否多选,默认 true */
multiple?: boolean;
/** 最大选择数,超出截断 */
maxCount?: number;
placeholder?: string;
allowClear?: boolean;
showSearch?: boolean;
disabled?: boolean;
}>(),
{
modelValue: () => [],
multiple: true,
maxCount: 99,
placeholder: '请选择',
allowClear: true,
showSearch: true,
disabled: false,
},
);
const emit = defineEmits<{
'update:modelValue': [v: (string | number)[]];
}>();
// 双向绑定useVModel 自动处理 modelValue 的读写
const innerValue = useVModel(props, 'modelValue', emit, {
defaultValue: [] as (string | number)[],
passive: true,
});
/** 用于 Select 的 mode单选时不传 mode */
const selectMode = computed(() => (props.multiple ? 'multiple' : undefined));
/** 按 value 索引选项,方便 tagRender/option 插槽快速查头像与名字 */
const optionMap = computed(() => {
const map = new Map<string | number, UserTagOption>();
for (const opt of props.options) {
map.set(opt.value, opt);
}
return map;
});
/** 取某 value 对应的展示名 */
function labelOf(value: string | number) {
return optionMap.value.get(value)?.label || String(value);
}
/** 取某 value 对应的头像 */
function avatarOf(value: string | number) {
return optionMap.value.get(value)?.avatar;
}
/** 取首字符(文本头像) */
function firstChar(label?: string) {
return (label || '?').trim().charAt(0) || '?';
}
/** 选项过滤:按 label 包含输入关键字 */
function filterOption(input: string, option: any) {
const v = String(option?.label || '').toLowerCase();
return v.includes((input || '').toLowerCase());
}
/**
* change 处理:
* - 单选:把单值包成数组
* - 多选:超 maxCount 截断
*/
function onChange(v: any) {
if (!props.multiple) {
innerValue.value = v == null ? [] : [v];
return;
}
if (Array.isArray(v) && v.length > props.maxCount) {
v = v.slice(0, props.maxCount);
}
innerValue.value = v;
}
</script>
<template>
<Select
:value="innerValue as any"
:mode="selectMode"
:options="options"
:placeholder="placeholder"
:allow-clear="allowClear"
:show-search="showSearch"
:filter-option="filterOption"
:disabled="disabled"
:max-tag-count="10"
option-filter-prop="label"
style="width: 100%"
@change="onChange"
>
<!-- 多选已选项展示头像+名字 -->
<template v-if="multiple" #tagRender="{ value, closable, onClose }">
<Tag :closable="closable" @close="onClose" class="user-tag-chip">
<Avatar :size="16" :src="avatarOf(value)">
{{ firstChar(labelOf(value)) }}
</Avatar>
<span class="user-tag-name">{{ labelOf(value) }}</span>
</Tag>
</template>
<!-- 下拉选项展示头像+名字 -->
<template #option="{ label, avatar }">
<div class="user-tag-option">
<Avatar :size="20" :src="avatar">{{ firstChar(label) }}</Avatar>
<span>{{ label }}</span>
</div>
</template>
</Select>
</template>
<style scoped>
.user-tag-chip {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 6px;
}
.user-tag-name {
max-width: 80px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.user-tag-option {
display: flex;
align-items: center;
gap: 8px;
}
</style>