Compare commits

...

9 Commits

Author SHA1 Message Date
old burden 1edf9601c0 fix: 仿照即梦出一版 2026-03-31 13:49:48 +08:00
old burden adcfea0d3c fix: 多语言设置 2026-03-30 17:21:56 +08:00
old burden 33a10f55a5 fix: 个人中心隐藏有奖邀请和邀请消费记录,多语言选择改为disable 2026-03-30 17:21:38 +08:00
old burden 55588504a1 fix: 侧边栏优化,内敛图调大 2026-03-30 13:50:38 +08:00
old burden d69d16e196 fix: bug修改 result 写成 URL 2026-03-30 13:25:53 +08:00
old burden 5ba6c9f746 fix: 页面优化,工具栏显示 2026-03-30 13:05:05 +08:00
old burden 0e94beb477 fix: bug修改字段缺失 2026-03-30 12:30:05 +08:00
old burden 5ae8614b1d fix: 参数选择改到参数文件 2026-03-30 12:06:46 +08:00
old burden 4bba35a426 fix: 新页面 2026-03-30 11:07:36 +08:00
22 changed files with 4124 additions and 418 deletions

View File

@ -5,4 +5,4 @@ VUE_APP_TITLE = 管理系统
ENV = 'production'
# 若依管理系统/生产环境
VUE_APP_BASE_API = 'http://47.86.170.114:8011'
VUE_APP_BASE_API = 'http://111.230.37.169:10009'

View File

@ -0,0 +1,46 @@
import request from '@/utils/request'
export function listDept(query) {
return request({
url: '/ai/dept/list',
method: 'get',
params: query
})
}
export function listDeptExcludeChild(deptId) {
return request({
url: '/ai/dept/list/exclude/' + deptId,
method: 'get'
})
}
export function getDept(deptId) {
return request({
url: '/ai/dept/' + deptId,
method: 'get'
})
}
export function addDept(data) {
return request({
url: '/ai/dept',
method: 'post',
data: data
})
}
export function updateDept(data) {
return request({
url: '/ai/dept',
method: 'put',
data: data
})
}
export function delDept(deptId) {
return request({
url: '/ai/dept/' + deptId,
method: 'delete'
})
}

View File

@ -81,3 +81,12 @@ export function updatePassword(id, newPassword) {
data: data
})
}
/** 分配归属部门deptId 可省略或 null 表示清空 */
export function assignAiUserDept(data) {
return request({
url: '/ai/user/dept',
method: 'put',
data
})
}

View File

@ -0,0 +1,344 @@
<template>
<div class="app-container">
<el-form :model="queryParams" ref="queryForm" size="small" :inline="true" v-show="showSearch">
<el-form-item label="部门名称" prop="deptName">
<el-input
v-model="queryParams.deptName"
placeholder="请输入部门名称"
clearable
@keyup.enter.native="handleQuery"
/>
</el-form-item>
<el-form-item label="状态" prop="status">
<el-select v-model="queryParams.status" placeholder="部门状态" clearable>
<el-option
v-for="dict in dict.type.sys_normal_disable"
:key="dict.value"
:label="dict.label"
:value="dict.value"
/>
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
<el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
<el-row :gutter="10" class="mb8">
<el-col :span="1.5">
<el-button
type="primary"
plain
icon="el-icon-plus"
size="mini"
@click="handleAdd"
v-hasPermi="['ai:dept:add']"
>新增</el-button>
</el-col>
<el-col :span="1.5">
<el-button
type="info"
plain
icon="el-icon-sort"
size="mini"
@click="toggleExpandAll"
>展开/折叠</el-button>
</el-col>
<right-toolbar :showSearch.sync="showSearch" @queryTable="getList"></right-toolbar>
</el-row>
<el-table
v-if="refreshTable"
v-loading="loading"
:data="deptList"
row-key="deptId"
:default-expand-all="isExpandAll"
:tree-props="{children: 'children', hasChildren: 'hasChildren'}"
>
<el-table-column prop="deptName" label="部门名称" width="260"></el-table-column>
<el-table-column prop="orderNum" label="排序" width="200"></el-table-column>
<el-table-column prop="status" label="状态" width="100">
<template slot-scope="scope">
<dict-tag :options="dict.type.sys_normal_disable" :value="scope.row.status"/>
</template>
</el-table-column>
<el-table-column label="创建时间" align="center" prop="createTime" width="200">
<template slot-scope="scope">
<span>{{ parseTime(scope.row.createTime) }}</span>
</template>
</el-table-column>
<el-table-column label="操作" align="center" class-name="small-padding fixed-width">
<template slot-scope="scope">
<el-button
size="mini"
type="text"
icon="el-icon-edit"
@click="handleUpdate(scope.row)"
v-hasPermi="['ai:dept:edit']"
>修改</el-button>
<el-button
size="mini"
type="text"
icon="el-icon-plus"
@click="handleAdd(scope.row)"
v-hasPermi="['ai:dept:add']"
>新增</el-button>
<el-button
v-if="scope.row.parentId != 0"
size="mini"
type="text"
icon="el-icon-delete"
@click="handleDelete(scope.row)"
v-hasPermi="['ai:dept:remove']"
>删除</el-button>
</template>
</el-table-column>
</el-table>
<el-dialog :title="title" :visible.sync="open" width="600px" append-to-body>
<el-form ref="form" :model="form" :rules="rules" label-width="80px">
<el-row>
<el-col :span="24" v-if="form.parentId !== 0">
<el-form-item label="上级部门" prop="parentId">
<treeselect v-model="form.parentId" :options="deptOptions" :normalizer="normalizer" placeholder="选择上级部门" />
</el-form-item>
</el-col>
</el-row>
<el-row>
<el-col :span="12">
<el-form-item label="部门名称" prop="deptName">
<el-input v-model="form.deptName" placeholder="请输入部门名称" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="显示排序" prop="orderNum">
<el-input-number v-model="form.orderNum" controls-position="right" :min="0" />
</el-form-item>
</el-col>
</el-row>
<el-row>
<el-col :span="12">
<el-form-item label="负责人" prop="leader">
<el-input v-model="form.leader" placeholder="请输入负责人" maxlength="20" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="联系电话" prop="phone">
<el-input v-model="form.phone" placeholder="请输入联系电话" maxlength="11" />
</el-form-item>
</el-col>
</el-row>
<el-row>
<el-col :span="12">
<el-form-item label="邮箱" prop="email">
<el-input v-model="form.email" placeholder="请输入邮箱" maxlength="50" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="部门状态">
<el-radio-group v-model="form.status">
<el-radio
v-for="dict in dict.type.sys_normal_disable"
:key="dict.value"
:label="dict.value"
>{{dict.label}}</el-radio>
</el-radio-group>
</el-form-item>
</el-col>
</el-row>
<el-row v-if="isSecondLevelCompanyForm">
<el-col :span="24">
<el-form-item label="Byte API Key">
<el-input
v-model="form.byteApiKey"
type="password"
show-password
placeholder="选填"
maxlength="255"
/>
</el-form-item>
</el-col>
</el-row>
</el-form>
<div slot="footer" class="dialog-footer">
<el-button type="primary" @click="submitForm"> </el-button>
<el-button @click="cancel"> </el-button>
</div>
</el-dialog>
</div>
</template>
<script>
import { listDept, getDept, delDept, addDept, updateDept, listDeptExcludeChild } from "@/api/ai/dept"
import Treeselect from "@riophae/vue-treeselect"
import "@riophae/vue-treeselect/dist/vue-treeselect.css"
export default {
name: "AiDept",
dicts: ['sys_normal_disable'],
components: { Treeselect },
data() {
return {
loading: true,
showSearch: true,
deptList: [],
deptOptions: [],
title: "",
open: false,
isExpandAll: true,
refreshTable: true,
queryParams: {
deptName: undefined,
status: undefined
},
form: {},
rules: {
parentId: [
{ required: true, message: "上级部门不能为空", trigger: "blur" }
],
deptName: [
{ required: true, message: "部门名称不能为空", trigger: "blur" }
],
orderNum: [
{ required: true, message: "显示排序不能为空", trigger: "blur" }
],
email: [
{
type: "email",
message: "请输入正确的邮箱地址",
trigger: ["blur", "change"]
}
],
phone: [
{
pattern: /^1[3|4|5|6|7|8|9][0-9]\d{8}$/,
message: "请输入正确的手机号码",
trigger: "blur"
}
]
}
}
},
created() {
this.getList()
},
computed: {
isSecondLevelCompanyForm() {
if (this.form.ancestors === "0,100") {
return true
}
const pid = this.form.parentId
if (pid !== undefined && pid !== null && Number(pid) === 100) {
return true
}
return false
}
},
methods: {
getList() {
this.loading = true
listDept(this.queryParams).then(response => {
this.deptList = this.handleTree(response.data, "deptId")
this.loading = false
})
},
normalizer(node) {
if (node.children && !node.children.length) {
delete node.children
}
return {
id: node.deptId,
label: node.deptName,
children: node.children
}
},
cancel() {
this.open = false
this.reset()
},
reset() {
this.form = {
deptId: undefined,
parentId: undefined,
ancestors: undefined,
deptName: undefined,
orderNum: undefined,
leader: undefined,
phone: undefined,
email: undefined,
byteApiKey: undefined,
status: "0"
}
this.resetForm("form")
},
handleQuery() {
this.getList()
},
resetQuery() {
this.resetForm("queryForm")
this.handleQuery()
},
handleAdd(row) {
this.reset()
if (row != undefined) {
this.form.parentId = row.deptId
}
this.open = true
this.title = "添加部门"
listDept().then(response => {
this.deptOptions = this.handleTree(response.data, "deptId")
})
},
toggleExpandAll() {
this.refreshTable = false
this.isExpandAll = !this.isExpandAll
this.$nextTick(() => {
this.refreshTable = true
})
},
handleUpdate(row) {
this.reset()
getDept(row.deptId).then(response => {
this.form = response.data
this.open = true
this.title = "修改部门"
listDeptExcludeChild(row.deptId).then(response => {
this.deptOptions = this.handleTree(response.data, "deptId")
if (this.deptOptions.length == 0) {
const noResultsOptions = { deptId: this.form.parentId, deptName: this.form.parentName, children: [] }
this.deptOptions.push(noResultsOptions)
}
})
})
},
submitForm: function() {
this.$refs["form"].validate(valid => {
if (valid) {
if (this.form.deptId != undefined) {
updateDept(this.form).then(response => {
this.$modal.msgSuccess("修改成功")
this.open = false
this.getList()
})
} else {
addDept(this.form).then(response => {
this.$modal.msgSuccess("新增成功")
this.open = false
this.getList()
})
}
}
})
},
handleDelete(row) {
this.$modal.confirm('是否确认删除名称为"' + row.deptName + '"的数据项?').then(function() {
return delDept(row.deptId)
}).then(() => {
this.getList()
this.$modal.msgSuccess("删除成功")
}).catch(() => {})
}
}
}
</script>

View File

@ -50,6 +50,16 @@
/>
</el-select>
</el-form-item>
<el-form-item label="归属部门" prop="deptId">
<treeselect
v-model="queryParams.deptId"
:options="deptOptions"
:normalizer="deptNormalizer"
placeholder="全部"
clearable
style="width: 220px"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
<el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
@ -78,6 +88,7 @@
<el-table-column label="上级ID" align="center" prop="superiorUuid" />
<el-table-column label="上级账号" align="center" prop="superiorName" />
<el-table-column label="用户昵称" align="center" prop="nickname" />
<el-table-column label="归属部门" align="center" prop="deptName" width="120" show-overflow-tooltip />
<el-table-column label="邮箱" align="center" prop="email" />
<el-table-column label="性别" align="center" prop="gender">
<template slot-scope="scope">
@ -103,8 +114,15 @@
</el-table-column>
<el-table-column label="余额" align="center" prop="balance" />
<el-table-column label="source" align="center" prop="source" />
<el-table-column label="操作" align="center" class-name="small-padding fixed-width" width="180">
<el-table-column label="操作" align="center" class-name="small-padding fixed-width" width="250">
<template slot-scope="scope">
<el-button
size="mini"
type="text"
icon="el-icon-office-building"
@click="handleOpenAssignDept(scope.row)"
v-hasPermi="['ai:user:edit']"
>分配部门</el-button>
<el-button
size="mini"
type="text"
@ -182,6 +200,25 @@
</div>
</el-dialog>
<!-- 分配部门 -->
<el-dialog title="分配归属部门" :visible.sync="assignDeptOpen" width="480px" append-to-body @close="cancelAssignDept">
<el-form label-width="88px">
<el-form-item label="归属部门">
<treeselect
v-model="assignForm.deptId"
:options="deptOptions"
:normalizer="deptNormalizer"
placeholder="不选则不归属任何部门"
clearable
/>
</el-form-item>
</el-form>
<div slot="footer" class="dialog-footer">
<el-button type="primary" @click="submitAssignDept"> </el-button>
<el-button @click="cancelAssignDept"> </el-button>
</div>
</el-dialog>
<!-- 修改余额对话框 -->
<el-dialog :title="title" :visible.sync="openUpdateBalance" width="500px" append-to-body>
<el-form ref="form" :model="form" :rules="rules" label-width="80px">
@ -207,12 +244,17 @@ import {
updateUser,
changeBalance,
changeUserStatus,
updatePassword
updatePassword,
assignAiUserDept
} from "@/api/ai/user";
import { listDept } from "@/api/ai/dept";
import Treeselect from "@riophae/vue-treeselect";
import "@riophae/vue-treeselect/dist/vue-treeselect.css";
export default {
name: "User",
dicts: ["sys_normal_disable", "sys_user_sex"],
components: { Treeselect },
data() {
return {
//
@ -235,6 +277,12 @@ export default {
open: false,
openUpdatePassword: false,
openUpdateBalance: false,
assignDeptOpen: false,
deptOptions: [],
assignForm: {
id: null,
deptId: null
},
//
queryParams: {
pageNum: 1,
@ -252,7 +300,8 @@ export default {
paymentUrl: null,
loginTime: null,
balance: null,
superiorName: null
superiorName: null,
deptId: null
},
//
form: {},
@ -268,9 +317,49 @@ export default {
};
},
created() {
this.loadDeptTree();
this.getList();
},
methods: {
loadDeptTree() {
listDept().then(res => {
this.deptOptions = this.handleTree(res.data, "deptId");
});
},
deptNormalizer(node) {
if (node.children && !node.children.length) {
delete node.children;
}
return {
id: node.deptId,
label: node.deptName,
children: node.children
};
},
handleOpenAssignDept(row) {
getUser(row.id).then(res => {
this.assignForm = {
id: res.data.id,
deptId: res.data.deptId
};
this.assignDeptOpen = true;
});
},
submitAssignDept() {
const payload = {
id: this.assignForm.id,
deptId: this.assignForm.deptId != null ? this.assignForm.deptId : null
};
assignAiUserDept(payload).then(() => {
this.$modal.msgSuccess("已保存");
this.assignDeptOpen = false;
this.getList();
});
},
cancelAssignDept() {
this.assignDeptOpen = false;
this.assignForm = { id: null, deptId: null };
},
//
handleCommand(command, row) {
switch (command) {

View File

@ -148,6 +148,19 @@
</el-form-item>
</el-col>
</el-row>
<el-row v-if="isSecondLevelCompanyForm">
<el-col :span="24">
<el-form-item label="Byte API Key">
<el-input
v-model="form.byteApiKey"
type="password"
show-password
placeholder="选填"
maxlength="255"
/>
</el-form-item>
</el-col>
</el-row>
</el-form>
<div slot="footer" class="dialog-footer">
<el-button type="primary" @click="submitForm"> </el-button>
@ -222,6 +235,19 @@ export default {
created() {
this.getList()
},
computed: {
/** 二级公司ancestors 为 0,100即上级为根公司 dept_id=100 */
isSecondLevelCompanyForm() {
if (this.form.ancestors === "0,100") {
return true
}
const pid = this.form.parentId
if (pid !== undefined && pid !== null && Number(pid) === 100) {
return true
}
return false
}
},
methods: {
/** 查询部门列表 */
getList() {
@ -252,11 +278,13 @@ export default {
this.form = {
deptId: undefined,
parentId: undefined,
ancestors: undefined,
deptName: undefined,
orderNum: undefined,
leader: undefined,
phone: undefined,
email: undefined,
byteApiKey: undefined,
status: "0"
}
this.resetForm("form")

View File

@ -34,6 +34,8 @@
</template>
<script>
import i18n from '@/lang/i18n'
export default {
name: 'mf-forbidden',
data() {
@ -49,7 +51,13 @@ export default {
this.$router.replace('/403')
},
ok() {
this.$store.dispatch('main/setForbidden', false)
// 18+
Promise.resolve(this.$store.dispatch('main/setForbidden', false)).finally(() => {
// zh_HK
this.$store.dispatch('main/setLanguage', 'zh_HK')
i18n.global.locale = 'zh_HK'
this.$router.push({ name: 'video-gen' })
})
}
}
}

View File

@ -50,7 +50,7 @@
<script>
import { nextTick, onMounted, ref, watch } from 'vue'
import { uploadFile } from '@/utils/file'
import { uploadFile, extractUploadUrlFromResponse, PORTAL_TENCENT_COS_UPLOAD_URL } from '@/utils/file'
export default {
name: 'RichTextEditor',
@ -105,13 +105,13 @@ export default {
//
const res = await uploadFile({
url: '/api/cos/upload', // 使COS
url: PORTAL_TENCENT_COS_UPLOAD_URL,
file: file,
name: 'file'
})
if (res && res.code === 200 && res.data) {
const imageUrl = typeof res.data === 'string' ? res.data : (res.data.url || res.data)
const imageUrl = extractUploadUrlFromResponse(res)
if (res && (Number(res.code) === 200 || res.code === 200) && imageUrl) {
insertImage(imageUrl, file.name)
// @

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,591 @@
<template>
<div class="video-editor-root">
<div v-if="showToolbar" class="toolbar">
<button class="tool-btn" type="button" @click="openImagePicker">插入参考素材</button>
<button class="tool-btn" type="button" @click="clear">清空所有参考素材和文字</button>
</div>
<input
ref="fileInputRef"
class="hidden-input"
type="file"
accept="image/*,video/*,audio/*"
multiple
@change="handleSelectFiles" />
<div class="editor-wrapper">
<div
ref="editorRef"
class="user-input video-rich-editor"
contenteditable="true"
:data-placeholder="placeholder || '请输入文本生成视频...'"
@input="handleInput"
@paste="handlePaste"
@keyup="handleKeyup"
@click="handleEditorClick"></div>
<div v-if="mentionVisible" class="mention-panel">
<div
v-for="item in mentionImageList"
:key="item.url"
class="mention-item"
@mousedown.prevent="insertMentionImage(item)">
<img v-if="item.mediaType === 'image'" :src="item.url" class="mention-thumb" alt="" />
<span v-else class="mention-type-badge">{{ item.mediaType === 'video' ? '视频' : '音频' }}</span>
<span class="mention-label">参考{{ item.refNo }}</span>
</div>
<div v-if="mentionImageList.length === 0" class="mention-empty">暂无可引用素材</div>
</div>
</div>
</div>
</template>
<script setup>
import { nextTick, onMounted, ref } from 'vue'
import { Message } from '@arco-design/web-vue'
import { uploadFile, extractUploadUrlFromResponse, PORTAL_TENCENT_COS_UPLOAD_URL } from '@/utils/file'
const props = defineProps({
placeholder: {
type: String,
default: ''
},
/** 文生视频等场景可隐藏工具栏,仅保留纯文本编辑 */
showToolbar: {
type: Boolean,
default: true
}
})
const emit = defineEmits(['text-change'])
const editorRef = ref(null)
const fileInputRef = ref(null)
const savedSelectionRange = ref(null)
const MAX_IMAGE_COUNT = 9
const mentionVisible = ref(false)
const mentionImageList = ref([])
const detectMediaType = (file) => {
const mime = (file?.type || '').toLowerCase()
if (mime.startsWith('image/')) return 'image'
if (mime.startsWith('video/')) return 'video'
if (mime.startsWith('audio/')) return 'audio'
const lowerName = (file?.name || '').toLowerCase()
if (/\.(png|jpe?g|gif|webp|bmp|svg)$/.test(lowerName)) return 'image'
if (/\.(mp4|mov|webm|m4v|avi|mkv)$/.test(lowerName)) return 'video'
if (/\.(mp3|wav|aac|m4a|ogg|flac)$/.test(lowerName)) return 'audio'
return 'image'
}
const getPlainText = () => (editorRef.value?.innerText || '').trim()
const focusEditorToEnd = () => {
if (!editorRef.value) return
editorRef.value.focus()
const selection = window.getSelection()
if (!selection) return
const range = document.createRange()
range.selectNodeContents(editorRef.value)
range.collapse(false)
selection.removeAllRanges()
selection.addRange(range)
savedSelectionRange.value = range.cloneRange()
}
const saveSelection = () => {
const selection = window.getSelection()
if (!selection || selection.rangeCount === 0) return
const range = selection.getRangeAt(0)
if (editorRef.value?.contains(range.commonAncestorContainer)) {
savedSelectionRange.value = range.cloneRange()
}
}
const restoreSelection = () => {
const selection = window.getSelection()
if (!selection || !editorRef.value) return
selection.removeAllRanges()
if (savedSelectionRange.value) {
selection.addRange(savedSelectionRange.value)
} else {
const range = document.createRange()
range.selectNodeContents(editorRef.value)
range.collapse(false)
selection.addRange(range)
}
}
const handleInput = () => {
emit('text-change', getPlainText())
saveSelection()
syncMentionPanelVisibility()
}
const insertPlainTextAtCursor = (text) => {
if (!editorRef.value) return
const normalizedText = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
editorRef.value.focus()
restoreSelection()
const selection = window.getSelection()
if (!selection || selection.rangeCount === 0) return
let range = selection.getRangeAt(0)
if (!editorRef.value.contains(range.commonAncestorContainer)) {
focusEditorToEnd()
const currentSelection = window.getSelection()
if (!currentSelection || currentSelection.rangeCount === 0) return
range = currentSelection.getRangeAt(0)
}
range.deleteContents()
const lines = normalizedText.split('\n')
const fragment = document.createDocumentFragment()
lines.forEach((line, index) => {
if (line) fragment.appendChild(document.createTextNode(line))
if (index < lines.length - 1) fragment.appendChild(document.createElement('br'))
})
range.insertNode(fragment)
range.collapse(false)
selection.removeAllRanges()
selection.addRange(range)
saveSelection()
}
const handlePaste = (event) => {
if (!editorRef.value) return
event.preventDefault()
const clipboardText = event.clipboardData?.getData('text/plain') || ''
if (!clipboardText) return
insertPlainTextAtCursor(clipboardText)
emit('text-change', getPlainText())
syncMentionPanelVisibility()
}
const updateMentionImageList = () => {
mentionImageList.value = mentionImageList.value.slice(0, MAX_IMAGE_COUNT)
}
const hideMentionPanel = () => {
mentionVisible.value = false
}
const hasActiveMentionTriggerBeforeCursor = () => {
const selection = window.getSelection()
if (!selection || selection.rangeCount === 0) return false
const range = selection.getRangeAt(0)
if (!range.collapsed) return false
if (!editorRef.value?.contains(range.commonAncestorContainer)) return false
const container = range.startContainer
if (container.nodeType !== Node.TEXT_NODE) return false
const before = container.data.slice(0, range.startOffset)
const atIndex = before.lastIndexOf('@')
if (atIndex < 0) return false
const afterAt = before.slice(atIndex + 1)
return !/\s/.test(afterAt)
}
const syncMentionPanelVisibility = () => {
const shouldShow = hasActiveMentionTriggerBeforeCursor()
if (!shouldShow) {
hideMentionPanel()
return
}
updateMentionImageList()
mentionVisible.value = true
}
const removeMentionKeywordBeforeCursor = () => {
const selection = window.getSelection()
if (!selection || selection.rangeCount === 0) return
const range = selection.getRangeAt(0)
if (!range.collapsed) return
const container = range.startContainer
if (container.nodeType !== Node.TEXT_NODE) return
const before = container.data.slice(0, range.startOffset)
const atIndex = before.lastIndexOf('@')
if (atIndex < 0) return
const afterAt = before.slice(atIndex + 1)
if (/\s/.test(afterAt)) return
const removeRange = document.createRange()
removeRange.setStart(container, atIndex)
removeRange.setEnd(container, range.startOffset)
removeRange.deleteContents()
}
const insertReference = (item) => {
if (!item?.url || !editorRef.value) return
editorRef.value.focus()
restoreSelection()
const selection = window.getSelection()
if (!selection || selection.rangeCount === 0) return
const range = selection.getRangeAt(0)
if (!editorRef.value.contains(range.commonAncestorContainer)) return
const refNoText = `[图${item.refNo}]`
let node
if (item.mediaType === 'image') {
const img = document.createElement('img')
img.src = item.url
img.alt = `reference-${item.refNo}`
img.className = 'inline-rich-image'
img.setAttribute('data-image-url', item.url)
img.setAttribute('data-mention-reference', '1')
img.setAttribute('data-reference-no', String(item.refNo))
node = img
} else {
const tag = document.createElement('span')
tag.className = 'inline-rich-reference'
tag.textContent = refNoText
tag.setAttribute('data-reference-url', item.url)
tag.setAttribute('data-mention-reference', '1')
tag.setAttribute('data-reference-no', String(item.refNo))
tag.setAttribute('data-reference-type', item.mediaType)
node = tag
}
range.insertNode(node)
const space = document.createTextNode(' ')
range.setStartAfter(node)
range.insertNode(space)
range.setStartAfter(space)
range.collapse(true)
selection.removeAllRanges()
selection.addRange(range)
saveSelection()
}
const insertMentionImage = (item) => {
if (!item?.url || !editorRef.value) return
editorRef.value.focus()
restoreSelection()
removeMentionKeywordBeforeCursor()
// @ insertReference restoreSelection Range
saveSelection()
insertReference(item)
hideMentionPanel()
emit('text-change', getPlainText())
}
const handleKeyup = (event) => {
saveSelection()
if (event.key === '@') {
syncMentionPanelVisibility()
return
}
if (event.key === 'Escape') {
hideMentionPanel()
return
}
syncMentionPanelVisibility()
}
const handleEditorClick = () => {
const selection = window.getSelection()
if (!selection || selection.rangeCount === 0) {
focusEditorToEnd()
return
}
const range = selection.getRangeAt(0)
if (!editorRef.value?.contains(range.commonAncestorContainer)) {
focusEditorToEnd()
return
}
saveSelection()
syncMentionPanelVisibility()
}
const getInsertedUniqueImageCount = () => mentionImageList.value.length
const openImagePicker = () => {
const current = getInsertedUniqueImageCount()
if (current >= MAX_IMAGE_COUNT) {
Message.warning(`最多插入 ${MAX_IMAGE_COUNT} 个参考素材`)
return
}
fileInputRef.value?.click()
}
const uploadToCos = async (file) => {
const res = await uploadFile({
url: PORTAL_TENCENT_COS_UPLOAD_URL,
file,
name: 'file'
})
const codeOk = res && (Number(res.code) === 200 || res.code === 200)
const url = extractUploadUrlFromResponse(res)
if (codeOk && url) return url
throw new Error(res?.msg || '上传失败:未返回文件地址')
}
const handleSelectFiles = async (event) => {
const input = event.target
const files = Array.from(input.files || [])
if (!files.length) return
const remain = MAX_IMAGE_COUNT - getInsertedUniqueImageCount()
if (remain <= 0) {
Message.warning(`最多插入 ${MAX_IMAGE_COUNT} 个参考素材`)
input.value = ''
return
}
const selected = files.slice(0, remain)
if (files.length > remain) {
Message.warning(`最多插入 ${MAX_IMAGE_COUNT} 个参考素材,本次仅插入 ${remain}`)
}
for (const file of selected) {
const mediaType = detectMediaType(file)
try {
const url = await uploadToCos(file)
if (url && !mentionImageList.value.some((item) => item.url === url)) {
const refNo = mentionImageList.value.length + 1
mentionImageList.value.push({ url, refNo, mediaType })
}
} catch (e) {
Message.error('参考素材上传失败')
console.error(e)
}
}
input.value = ''
emit('text-change', getPlainText())
}
const getContentItems = () => {
const editor = editorRef.value
const items = []
if (!editor) return items
let textBuffer = ''
const flushText = () => {
const normalized = textBuffer.replace(/\u00a0/g, ' ').replace(/\s+\n/g, '\n')
if (normalized.trim()) items.push({ type: 'text', text: normalized.trim() })
textBuffer = ''
}
const walk = (node) => {
if (node.nodeType === Node.TEXT_NODE) {
textBuffer += node.textContent || ''
return
}
if (node.nodeType !== Node.ELEMENT_NODE) return
const el = node
const referenceUrl = (
el.dataset?.referenceUrl ||
el.dataset?.imageUrl ||
el.getAttribute('src') ||
''
).trim()
if (el.dataset?.mentionReference === '1' && referenceUrl) {
flushText()
items.push({
type: 'image_url',
image_url: { url: referenceUrl },
role: 'reference_image'
})
return
}
if (el.tagName === 'IMG' && (el.dataset?.imageUrl || el.getAttribute('src'))) {
return
}
if (el.tagName === 'BR') {
textBuffer += '\n'
return
}
if (['DIV', 'P', 'LI'].includes(el.tagName) && textBuffer && !textBuffer.endsWith('\n')) textBuffer += '\n'
Array.from(el.childNodes).forEach(walk)
if (['DIV', 'P', 'LI'].includes(el.tagName) && textBuffer && !textBuffer.endsWith('\n')) textBuffer += '\n'
}
Array.from(editor.childNodes).forEach(walk)
flushText()
return items
}
const clear = () => {
if (!editorRef.value) return
editorRef.value.innerHTML = ''
emit('text-change', '')
savedSelectionRange.value = null
mentionImageList.value = []
hideMentionPanel()
}
/** 供父组件持久化:参考图模式的 HTML + 素材列表 */
const getDraftState = () => ({
html: editorRef.value ? editorRef.value.innerHTML : '',
mentionList: mentionImageList.value.map(({ url, refNo, mediaType }) => ({
url,
refNo,
mediaType
}))
})
const applyDraftState = (draft) => {
hideMentionPanel()
savedSelectionRange.value = null
if (!draft || typeof draft !== 'object') {
clear()
return
}
mentionImageList.value = Array.isArray(draft.mentionList)
? draft.mentionList.map((x) => ({
url: x.url,
refNo: Number(x.refNo) || 0,
mediaType: x.mediaType || 'image'
}))
: []
if (editorRef.value) {
editorRef.value.innerHTML = typeof draft.html === 'string' ? draft.html : ''
}
nextTick(() => {
emit('text-change', getPlainText())
})
}
defineExpose({
getContentItems,
getPlainText,
clear,
getDraftState,
applyDraftState
})
onMounted(() => {
nextTick(() => {
focusEditorToEnd()
})
})
</script>
<style scoped>
.video-editor-root {
display: flex;
flex-direction: column;
gap: 8px;
flex: 1;
min-height: 0;
}
.toolbar {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.hidden-input {
display: none;
}
.editor-wrapper {
position: relative;
flex: 1;
display: flex;
flex-direction: column;
min-height: 200px;
}
.tool-btn {
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 8px;
background: rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.88);
padding: 6px 12px;
cursor: pointer;
font-size: 12px;
transition: border-color 0.2s, background 0.2s;
}
.tool-btn:hover {
border-color: rgba(0, 202, 224, 0.4);
background: rgba(0, 202, 224, 0.08);
}
.video-rich-editor {
width: 100%;
flex: 1;
min-height: 220px;
font-size: 15px;
line-height: 1.9;
white-space: pre-wrap;
word-break: break-word;
overflow-y: auto;
color: rgba(255, 255, 255, 0.9);
caret-color: #00cae0;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 14px;
padding: 16px 18px;
background: rgba(0, 0, 0, 0.25);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
}
.video-rich-editor:focus {
border-color: rgba(0, 202, 224, 0.45);
box-shadow: 0 0 0 2px rgba(0, 202, 224, 0.15);
outline: none;
}
.video-rich-editor:empty::before {
content: attr(data-placeholder);
color: rgba(255, 255, 255, 0.35);
pointer-events: none;
display: block;
}
.video-rich-editor :deep(.inline-rich-image) {
display: inline-block;
width: auto;
height: auto;
max-width: min(260px, 100%);
max-height: 148px;
object-fit: contain;
vertical-align: middle;
margin: 6px 8px;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.12);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
}
.video-rich-editor :deep(.inline-rich-reference) {
display: inline-flex;
align-items: center;
padding: 0 6px;
margin: 0 3px;
border-radius: 4px;
background: rgba(0, 202, 224, 0.15);
color: #5eebf5;
font-size: 0.85em;
line-height: 1.5;
}
.mention-panel {
position: absolute;
left: 8px;
right: 8px;
bottom: 8px;
max-height: 180px;
overflow-y: auto;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 10px;
background: rgba(22, 24, 30, 0.98);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
z-index: 20;
}
.mention-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
cursor: pointer;
}
.mention-item:hover {
background: rgba(255, 255, 255, 0.06);
}
.mention-thumb {
width: 32px;
height: 32px;
border-radius: 4px;
object-fit: cover;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.mention-type-badge {
min-width: 32px;
height: 32px;
border-radius: 4px;
border: 1px solid rgba(255, 255, 255, 0.1);
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 11px;
color: rgba(255, 255, 255, 0.65);
background: rgba(0, 0, 0, 0.25);
}
.mention-label {
font-size: 12px;
color: rgba(255, 255, 255, 0.85);
}
.mention-empty {
padding: 8px 10px;
font-size: 12px;
color: rgba(255, 255, 255, 0.4);
}
</style>

View File

@ -9,7 +9,8 @@ import ru_RU from '@/lang/ru_RU/index.js'
import ar_SA from '@/lang/ar_SA/index.js'
import fr_FR from '@/lang/fr_FR/index.js'
let locale = Cookies.get('language') || 'en_US'
// 多语言切换已禁用:全站固定繁体中文
let locale = 'zh_HK'
/** 各语言在界面上的显示名称 */
export const LOCALE_NAMES = {

View File

@ -23,9 +23,9 @@
<a-menu-item :key="item.key">
<template #icon>
<a-image
:width="24"
:width="28"
:preview="false"
:height="24"
:height="28"
:src="`/images/nav/${
selectedKeys.indexOf(item.key) > -1
? item.icon + '-a'
@ -50,6 +50,9 @@ import cloneDeep from 'lodash-es/cloneDeep'
import { constantRoutes } from '@/router/index'
import { generateTitle, generateLang } from '@/utils/i18n'
/** 左侧导航仅显示这些路由name 与 router/index.js 一致) */
const SIDEBAR_ONLY_ROUTE_NAMES = ['video-gen']
defineProps({
collapsed: Boolean
})
@ -62,9 +65,14 @@ const $base = inject('$base')
const $message = inject('$message')
const menuItems = computed(() => {
return constantRoutes
.find((d) => d.path == '/')
.children.map((route) => ({
const root = constantRoutes.find((d) => d.path === '/')
const children = root?.children ?? []
return children
.filter(
(r) =>
r.meta?.menuItem !== false && SIDEBAR_ONLY_ROUTE_NAMES.includes(r.name)
)
.map((route) => ({
key: route.name,
label: generateTitle(route.meta?.title),
meta: route.meta,

View File

@ -24,11 +24,8 @@
:title="$t('common.myAccount')">
<UserAccount />
</a-tab-pane>
<a-tab-pane
key="money"
:title="$t('common.moneyInvite')">
<Money />
</a-tab-pane>
<!-- 隐藏有奖邀请 -->
<!-- <a-tab-pane key="money" :title="$t('common.moneyInvite')"><Money /></a-tab-pane> -->
<!-- 隐藏快速充值入口 -->
<!-- <a-tab-pane
key="recharge"
@ -40,11 +37,8 @@
:title="$t('common.myProduct')">
<ResumeRecord />
</a-tab-pane>
<a-tab-pane
key="invite"
:title="$t('common.inviteRecord')">
<RewardRecord />
</a-tab-pane>
<!-- 隐藏邀请消费记录 -->
<!-- <a-tab-pane key="invite" :title="$t('common.inviteRecord')"><RewardRecord /></a-tab-pane> -->
</a-tabs>
</div>
</mf-dialog>
@ -64,12 +58,8 @@
<script>
import Forgot from './Forgot.vue'
import Register from './Register.vue'
import { mapGetters } from 'vuex'
import i18n from '@/lang/i18n'
import Money from './Money.vue'
import UserAccount from './UserAccount.vue'
import ResumeRecord from './ResumeRecord.vue'
import RewardRecord from './RewardRecord.vue'
export default {
data() {
@ -91,13 +81,8 @@ export default {
components: {
Forgot,
Register,
Money,
UserAccount,
ResumeRecord,
RewardRecord
},
computed: {
...mapGetters(['lang'])
ResumeRecord
},
mounted() {
if (this.register) {
@ -123,12 +108,6 @@ export default {
this.registerVisible = true
this.$emit('cancel')
},
changeLang(value) {
if (value != this.lang) {
this.$store.dispatch('main/setLanguage', value)
i18n.global.locale = value
}
},
back() {
this.forgotVisible = false
this.$emit('open')

View File

@ -24,6 +24,8 @@
<script>
import { mapGetters } from 'vuex'
import Breadcrumb from './Breadcrumb.vue'
const LAYOUT_MOBILE_MAX = 768
export default {
name: 'app-main',
props: {
@ -33,6 +35,21 @@ export default {
}
},
components: { Breadcrumb },
data() {
return {
layoutWidth:
typeof window !== 'undefined' ? window.innerWidth : LAYOUT_MOBILE_MAX + 1
}
},
mounted() {
this._layoutOnResize = () => {
this.layoutWidth = window.innerWidth
}
window.addEventListener('resize', this._layoutOnResize)
},
beforeUnmount() {
window.removeEventListener('resize', this._layoutOnResize)
},
computed: {
...mapGetters(['cachedViews', 'sidebar']),
//
@ -43,7 +60,10 @@ export default {
return !this.sidebar.opened
},
style() {
let sidebarWidth = this.isCollapsed ? '50px' : this.sidebar.width
if (this.layoutWidth <= LAYOUT_MOBILE_MAX) {
return { width: '100%' }
}
const sidebarWidth = this.isCollapsed ? '56px' : this.sidebar.width
return {
width: `calc(100% - ${sidebarWidth})`
}
@ -64,13 +84,15 @@ export default {
.app-main {
position: relative;
height: 100%;
overflow-y: auto;
overflow-x: hidden;
&-wrap {
height: 100%;
overflow: hidden;
}
}
@media (max-width: 576px) {
@media (max-width: 768px) {
.app-main {
&-wrap {
width: 100% !important;

View File

@ -1,11 +1,23 @@
<template>
<div class="navbar">
<div class="left-menu">
<mf-icon
:class="['left-collapse', { isCollapse: isCollapse }]"
cursor="pointer"
@click="$store.dispatch('main/toggleSideBar')"
value="icon-shrink" />
<button
type="button"
class="sidebar-trigger"
:aria-expanded="sidebar.opened"
aria-label="展开或收起侧栏"
@click="$store.dispatch('main/toggleSideBar')">
<mf-icon
class="sidebar-trigger__icon sidebar-trigger__icon--desktop"
:class="{ isCollapse: isCollapse }"
cursor="pointer"
value="icon-shrink" />
<span class="sidebar-trigger__icon sidebar-trigger__icon--mobile" aria-hidden="true">
<span class="hamburger-line" />
<span class="hamburger-line" />
<span class="hamburger-line" />
</span>
</button>
<div class="logo">
<div
class="logo-wrap"
@ -31,20 +43,9 @@
</mf-button>
</div>
<div class="right-menu-item language">
<a-dropdown @select="handleSelect">
<mf-button type="text">
{{ localeName }}
<icon-down />
</mf-button>
<template #content>
<a-doption
v-for="(name, code) in localeNames"
:key="code"
:value="code">
{{ name }}
</a-doption>
</template>
</a-dropdown>
<mf-button type="text" disabled class="language-display">
{{ localeName }}
</mf-button>
</div>
<div
class="right-menu-item user"
@ -97,7 +98,7 @@ import { mapGetters, mapState } from 'vuex'
import cloneDeep from 'lodash-es/cloneDeep'
import { constantRoutes } from '@/router/index.js'
import Login from './Login.vue'
import i18n, { LOCALE_NAMES } from '@/lang/i18n'
import { LOCALE_NAMES } from '@/lang/i18n'
import User from './User.vue'
export default {
@ -126,9 +127,6 @@ export default {
demoEnv() {
return import.meta.env.MODE === 'demo'
},
localeNames() {
return LOCALE_NAMES
},
localeName() {
return LOCALE_NAMES[this.lang] || 'English'
},
@ -219,12 +217,6 @@ export default {
this.openLogin()
}
},
handleSelect(value) {
if (value != this.lang) {
this.$store.dispatch('main/setLanguage', value)
i18n.global.locale = value
}
},
logout() {
this.$store.dispatch('user/logout2').then((_) => {
this.$router.replace('/')
@ -246,39 +238,71 @@ export default {
align-items: center;
justify-content: space-between;
box-sizing: content-box;
padding-left: 30px;
padding-left: 20px;
padding-right: 30px;
.left {
.left-menu {
display: flex;
align-items: center;
flex: 1;
padding-left: 4px;
height: 60px;
gap: 8px;
min-width: 0;
&-collapse {
display: none;
transition: 0.25s;
.sidebar-trigger {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
padding: 8px;
margin: 0;
border: none;
background: transparent;
cursor: pointer;
border-radius: 10px;
color: rgba(255, 255, 255, 0.92);
&.isCollapse {
transform: rotate(-180deg);
&:hover {
background: rgba(255, 255, 255, 0.08);
}
&__icon--desktop {
display: flex;
transition: transform 0.25s;
&.isCollapse {
transform: rotate(-180deg);
}
}
&__icon--mobile {
display: none;
flex-direction: column;
justify-content: center;
gap: 5px;
width: 22px;
padding: 2px 0;
}
}
&-menu {
padding-left: 8px;
.hamburger-line {
display: block;
height: 2px;
width: 100%;
border-radius: 1px;
background: currentColor;
}
.logo {
display: flex;
align-items: center;
height: 60px;
align-items: flex-end;
cursor: pointer;
color: var(--color-text-1);
.logo {
&-wrap {
display: flex;
align-items: flex-end;
cursor: pointer;
color: var(--color-text-1);
&-wrap {
display: flex;
align-items: center;
}
align-items: center;
}
}
}
@ -309,6 +333,12 @@ export default {
background-color: transparent;
}
}
.language-display[disabled] {
color: #999999 !important;
opacity: 0.85;
cursor: not-allowed;
}
}
&.logout {
@ -410,23 +440,23 @@ export default {
}
}
@media (max-width: 576px) {
@media (max-width: 768px) {
.navbar {
padding: 0 10px;
.left {
&-collapse {
display: block;
font-size: 18px;
padding: 0 12px;
.left-menu {
.sidebar-trigger {
&__icon--desktop {
display: none !important;
}
&__icon--mobile {
display: flex !important;
}
}
&-menu {
display: flex;
.logo {
display: none;
}
.mf-icon {
color: #fff;
}
.logo {
display: none;
}
}

View File

@ -124,7 +124,7 @@ export default {
1 100%;
&.collapsed {
width: 50px !important;
width: 56px !important;
overflow: hidden;
:deep(.arco-menu) {
@ -134,20 +134,27 @@ export default {
:deep(.arco-menu) {
background-color: transparent;
padding-left: 16px;
padding: 8px 12px 12px;
&-item {
width: 180px;
min-height: 48px;
line-height: 1.4;
font-size: 15px;
background-color: transparent;
color: rgba(255, 255, 255, 0.7);
border-radius: 10px;
margin-bottom: 12px;
margin-bottom: 10px;
&.arco-menu-selected,
&:hover {
background-color: rgb(var(--primary-6));
}
}
&:not(.arco-menu-collapsed) .arco-menu-item {
width: calc(100% - 8px);
max-width: 100%;
}
}
.toogle-menu {
@ -172,18 +179,22 @@ export default {
}
}
@media (max-width: 576px) {
@media (max-width: 768px) {
.sidebar-container {
position: fixed;
left: 0px;
top: 60px;
z-index: 10;
z-index: 100;
height: calc(100% - 60px);
overflow: hidden;
box-shadow: 8px 0 32px rgba(0, 0, 0, 0.35);
&.collapsed {
width: 0px !important;
min-width: 0 !important;
border-right: 0;
box-shadow: none;
pointer-events: none;
}
}
}

View File

@ -1,5 +1,10 @@
<template>
<div class="app-wrapper">
<div
v-show="showSidebar && mobileDrawerOpen"
class="sidebar-backdrop"
aria-hidden="true"
@click="closeMobileDrawer" />
<div class="fixed-header">
<nav-bar class="nav-bar" />
</div>
@ -18,6 +23,8 @@
import { mapGetters } from 'vuex'
import { sideBar, appMain, navBar, appFooter } from './components'
const LAYOUT_MOBILE_MAX = 768
export default {
name: 'layout',
components: {
@ -26,16 +33,44 @@ export default {
navBar,
appFooter
},
data() {
return {
layoutWidth:
typeof window !== 'undefined' ? window.innerWidth : LAYOUT_MOBILE_MAX + 1
}
},
computed: {
...mapGetters(['sidebar']),
isCollapsed() {
return !this.sidebar.opened
},
isMobileLayout() {
return this.layoutWidth <= LAYOUT_MOBILE_MAX
},
mobileDrawerOpen() {
return this.isMobileLayout && this.sidebar.opened
},
//
showSidebar() {
let { menu } = this.$route.query || {}
return menu != 0
}
},
mounted() {
this._layoutOnResize = () => {
this.layoutWidth = window.innerWidth
}
window.addEventListener('resize', this._layoutOnResize)
},
beforeUnmount() {
window.removeEventListener('resize', this._layoutOnResize)
},
methods: {
closeMobileDrawer() {
if (this.isMobileLayout && this.sidebar.opened) {
this.$store.dispatch('main/closeSideBar')
}
}
}
}
</script>
@ -49,6 +84,10 @@ export default {
overflow: hidden;
background: #0f0f12;
.sidebar-backdrop {
display: none;
}
.fixed-header {
z-index: 100;
.navbar {
@ -59,6 +98,18 @@ export default {
.main-wrapper {
display: flex;
height: calc(100% - 60px);
position: relative;
}
}
@media (max-width: 768px) {
.app-wrapper .sidebar-backdrop {
display: block;
position: fixed;
inset: 60px 0 0 0;
z-index: 99;
background: rgba(0, 0, 0, 0.55);
backdrop-filter: blur(2px);
}
}
</style>

View File

@ -16,11 +16,13 @@ const state = {
sidebar: {
opened: isMobile() ? false : true,
withoutAnimation: false,
width: Cookies.get('sidebarWidth') || '230px'
// 左侧导航默认适中宽度(用户若需更窄/宽可本地调小范围会写 cookie
width: Cookies.get('sidebarWidth') || '268px'
},
topMenu: Cookies.get('topMenu'),
// 当前语言
language: Cookies.get('language') || 'en_US',
// 多语言切换已禁用全站固定繁体中文zh_HK
language: 'zh_HK',
// 主题 '':亮色 / dark:暗黑
theme: $storage.get('theme') || '',
// 系统端

View File

@ -27,9 +27,12 @@ export const isFirefox = (_) => {
}
}
export const isMobile = _=> {
const userAgent = navigator.userAgent;
const isMobile = /iPad|Android|webOS|iPhone|iPod|BlackBerry|IEMobile|Opera Mini/i.test(userAgent);
const isSmallScreen = window.innerWidth <= 576;
return isMobile || isSmallScreen;
export const isMobile = (_) => {
const userAgent = navigator.userAgent
const uaMobile =
/iPad|Android|webOS|iPhone|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
userAgent
)
const isSmallScreen = typeof window !== 'undefined' && window.innerWidth <= 768
return uaMobile || isSmallScreen
}

View File

@ -130,6 +130,23 @@ export const convertBase64ToUrl = (base64) => {
)
}
/**
* 从若依 AjaxResult / 兼容仅返回顶层 url 的上传响应中解析访问地址
*/
export const extractUploadUrlFromResponse = (res) => {
if (!res) return ''
if (typeof res.data === 'string' && res.data.trim()) return res.data.trim()
if (res.data && typeof res.data === 'object') {
const u = res.data.url || res.data.path
if (typeof u === 'string' && u.trim()) return u.trim()
}
if (typeof res.url === 'string' && res.url.trim()) return res.url.trim()
return ''
}
/** 门户统一腾讯云 COS后端 FileController `/api/file/upload`,与 mf-image-upload 一致 */
export const PORTAL_TENCENT_COS_UPLOAD_URL = '/api/file/upload'
/**
* 文件上传
* @param {Object} {url, file,name} 文件上传地址 file 文件 name 文件名参数
@ -148,12 +165,10 @@ export const uploadFile = ({
formData.append(key, data[key])
})
}
// 不要手动设置 Content-Type否则缺少 boundary服务端无法解析 multipart文件参数字段为空
return request({
url: url,
method: 'POST',
data: formData,
headers: {
'Content-Type': 'multipart/form-data'
}
data: formData
})
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,71 @@
// vite.config.js
import {
defineConfig,
transformWithEsbuild
} from "file:///D:/AI%E9%A1%B9%E7%9B%AE%E4%BA%A4%E4%BB%98%E6%96%87%E4%BB%B6/AI%E9%A1%B9%E7%9B%AE%E4%BA%A4%E4%BB%98%E6%96%87%E4%BB%B6/%E5%B7%A5%E7%A8%8B%E4%BB%A3%E7%A0%81/portal-ui/node_modules/vite/dist/node/index.js";
import vue from "file:///D:/AI%E9%A1%B9%E7%9B%AE%E4%BA%A4%E4%BB%98%E6%96%87%E4%BB%B6/AI%E9%A1%B9%E7%9B%AE%E4%BA%A4%E4%BB%98%E6%96%87%E4%BB%B6/%E5%B7%A5%E7%A8%8B%E4%BB%A3%E7%A0%81/portal-ui/node_modules/@vitejs/plugin-vue/dist/index.mjs";
import vueJsx from "file:///D:/AI%E9%A1%B9%E7%9B%AE%E4%BA%A4%E4%BB%98%E6%96%87%E4%BB%B6/AI%E9%A1%B9%E7%9B%AE%E4%BA%A4%E4%BB%98%E6%96%87%E4%BB%B6/%E5%B7%A5%E7%A8%8B%E4%BB%A3%E7%A0%81/portal-ui/node_modules/@vitejs/plugin-vue-jsx/dist/index.mjs";
import {
vitePluginForArco
} from "file:///D:/AI%E9%A1%B9%E7%9B%AE%E4%BA%A4%E4%BB%98%E6%96%87%E4%BB%B6/AI%E9%A1%B9%E7%9B%AE%E4%BA%A4%E4%BB%98%E6%96%87%E4%BB%B6/%E5%B7%A5%E7%A8%8B%E4%BB%A3%E7%A0%81/portal-ui/node_modules/@arco-plugins/vite-vue/lib/index.js";
import {
resolve
} from "path";
import svgLoader from "file:///D:/AI%E9%A1%B9%E7%9B%AE%E4%BA%A4%E4%BB%98%E6%96%87%E4%BB%B6/AI%E9%A1%B9%E7%9B%AE%E4%BA%A4%E4%BB%98%E6%96%87%E4%BB%B6/%E5%B7%A5%E7%A8%8B%E4%BB%A3%E7%A0%81/portal-ui/node_modules/vite-svg-loader/index.js";
var __vite_injected_original_dirname = "D:\\AI\u9879\u76EE\u4EA4\u4ED8\u6587\u4EF6\\AI\u9879\u76EE\u4EA4\u4ED8\u6587\u4EF6\\\u5DE5\u7A0B\u4EE3\u7801\\portal-ui";
var vite_config_default = defineConfig({
publicDir: "static",
resolve: {
extensions: [".mjs", ".js", ".jsx", ".json"],
alias: [{
find: "@",
replacement: resolve(__vite_injected_original_dirname, "src")
}]
},
css: {
preprocessorOptions: {
less: {
modifyVars: {
"@size-9": "40px",
"arcoblue-6": "#e6217a"
}
}
}
},
plugins: [
vue({
template: {
transformAssetUrls: {
includeAbsolute: false
}
}
}),
svgLoader(),
vueJsx({
include: /\.[jt]sx?$/
}),
vitePluginForArco(),
{
name: "treat-js-files-as-jsx",
async transform(code, id) {
if (!id.match(/src\/.*\.js$/)) return null;
return transformWithEsbuild(code, id, {
loader: "jsx",
jsx: "automatic"
});
}
}
],
optimizeDeps: {
esbuildOptions: {
loader: {
".js": "jsx"
}
},
exclude: ["__INDEX__"]
}
});
export {
vite_config_default as default
};
//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsidml0ZS5jb25maWcuanMiXSwKICAic291cmNlc0NvbnRlbnQiOiBbImNvbnN0IF9fdml0ZV9pbmplY3RlZF9vcmlnaW5hbF9kaXJuYW1lID0gXCJEOlxcXFxBSVx1OTg3OVx1NzZFRVx1NEVBNFx1NEVEOFx1NjU4N1x1NEVGNlxcXFxBSVx1OTg3OVx1NzZFRVx1NEVBNFx1NEVEOFx1NjU4N1x1NEVGNlxcXFxcdTVERTVcdTdBMEJcdTRFRTNcdTc4MDFcXFxccG9ydGFsLXVpXCI7Y29uc3QgX192aXRlX2luamVjdGVkX29yaWdpbmFsX2ZpbGVuYW1lID0gXCJEOlxcXFxBSVx1OTg3OVx1NzZFRVx1NEVBNFx1NEVEOFx1NjU4N1x1NEVGNlxcXFxBSVx1OTg3OVx1NzZFRVx1NEVBNFx1NEVEOFx1NjU4N1x1NEVGNlxcXFxcdTVERTVcdTdBMEJcdTRFRTNcdTc4MDFcXFxccG9ydGFsLXVpXFxcXHZpdGUuY29uZmlnLmpzXCI7Y29uc3QgX192aXRlX2luamVjdGVkX29yaWdpbmFsX2ltcG9ydF9tZXRhX3VybCA9IFwiZmlsZTovLy9EOi9BSSVFOSVBMSVCOSVFNyU5QiVBRSVFNCVCQSVBNCVFNCVCQiU5OCVFNiU5NiU4NyVFNCVCQiVCNi9BSSVFOSVBMSVCOSVFNyU5QiVBRSVFNCVCQSVBNCVFNCVCQiU5OCVFNiU5NiU4NyVFNCVCQiVCNi8lRTUlQjclQTUlRTclQTglOEIlRTQlQkIlQTMlRTclQTAlODEvcG9ydGFsLXVpL3ZpdGUuY29uZmlnLmpzXCI7aW1wb3J0IHtcclxuXHRkZWZpbmVDb25maWcsXHJcblx0dHJhbnNmb3JtV2l0aEVzYnVpbGRcclxufSBmcm9tICd2aXRlJ1xyXG5pbXBvcnQgdnVlIGZyb20gJ0B2aXRlanMvcGx1Z2luLXZ1ZSdcclxuaW1wb3J0IHZ1ZUpzeCBmcm9tICdAdml0ZWpzL3BsdWdpbi12dWUtanN4J1xyXG5pbXBvcnQge1xyXG5cdHZpdGVQbHVnaW5Gb3JBcmNvXHJcbn0gZnJvbSAnQGFyY28tcGx1Z2lucy92aXRlLXZ1ZSdcclxuaW1wb3J0IHtcclxuXHRyZXNvbHZlXHJcbn0gZnJvbSAncGF0aCdcclxuaW1wb3J0IHN2Z0xvYWRlciBmcm9tICd2aXRlLXN2Zy1sb2FkZXInXHJcblxyXG5leHBvcnQgZGVmYXVsdCBkZWZpbmVDb25maWcoe1xyXG5cdHB1YmxpY0RpcjogJ3N0YXRpYycsXHJcblx0cmVzb2x2ZToge1xyXG5cdFx0ZXh0ZW5zaW9uczogWycubWpzJywgJy5qcycsICcuanN4JywgJy5qc29uJ10sXHJcblx0XHRhbGlhczogW3tcclxuXHRcdFx0ZmluZDogJ0AnLFxyXG5cdFx0XHRyZXBsYWNlbWVudDogcmVzb2x2ZShfX2Rpcm5hbWUsICdzcmMnKVxyXG5cdFx0fV1cclxuXHR9LFxyXG5cdGNzczoge1xyXG5cdFx0cHJlcHJvY2Vzc29yT3B0aW9uczoge1xyXG5cdFx0XHRsZXNzOiB7XHJcblx0XHRcdFx0bW9kaWZ5VmFyczoge1xyXG5cdFx0XHRcdFx0J0BzaXplLTknOiAnNDBweCcsXHJcblx0XHRcdFx0XHQnYXJjb2JsdWUtNic6ICcjZTYyMTdhJ1xyXG5cdFx0XHRcdH1cclxuXHRcdFx0fVxyXG5cdFx0fVxyXG5cdH0sXHJcblx0cGx1Z2luczogW1xyXG5cdFx0dnVlKHtcclxuXHRcdFx0dGVtcGxhdGU6IHtcclxuXHRcdFx0XHR0cmFuc2Zvcm1Bc3NldFVybHM6IHtcclxuXHRcdFx0XHRcdGluY2x1ZGVBYnNvbHV0ZTogZmFsc2VcclxuXHRcdFx0XHR9XHJcblx0XHRcdH1cclxuXHRcdH0pLFxyXG5cdFx0c3ZnTG9hZGVyKCksXHJcblx0XHR2dWVKc3goe1xyXG5cdFx0XHRpbmNsdWRlOiAvXFwuW2p0XXN4PyQvXHJcblx0XHR9KSxcclxuXHRcdHZpdGVQbHVnaW5Gb3JBcmNvKCksXHJcblx0XHR7XHJcblx0XHRcdG5hbWU6ICd0cmVhdC1qcy1maWxlcy1hcy1qc3gnLFxyXG5cdFx0XHRhc3luYyB0cmFuc2Zvcm0oY29kZSwgaWQpIHtcclxuXHRcdFx0XHRpZiAoIWlkLm1hdGNoKC9zcmNcXC8uKlxcLmpzJC8pKSByZXR1cm4gbnVsbFxyXG5cdFx0XHRcdHJldHVybiB0cmFuc2Zvcm1XaXRoRXNidWlsZChjb2RlLCBpZCwge1xyXG5cdFx0XHRcdFx0bG9hZGVyOiAnanN4JyxcclxuXHRcdFx0XHRcdGpzeDogJ2F1dG9tYXRpYydcclxuXHRcdFx0XHR9KVxyXG5cdFx0XHR9XHJcblx0XHR9XHJcblx0XSxcclxuXHRvcHRpbWl6ZURlcHM6IHtcclxuXHRcdGVzYnVpbGRPcHRpb25zOiB7XHJcblx0XHRcdGxvYWRlcjoge1xyXG5cdFx0XHRcdCcuanMnOiAnanN4J1xyXG5cdFx0XHR9XHJcblx0XHR9LFxyXG5cdFx0ZXhjbHVkZTogWydfX0lOREVYX18nXVxyXG5cdH1cclxufSkiXSwKICAibWFwcGluZ3MiOiAiO0FBQXFhO0FBQUEsRUFDcGE7QUFBQSxFQUNBO0FBQUEsT0FDTTtBQUNQLE9BQU8sU0FBUztBQUNoQixPQUFPLFlBQVk7QUFDbkI7QUFBQSxFQUNDO0FBQUEsT0FDTTtBQUNQO0FBQUEsRUFDQztBQUFBLE9BQ007QUFDUCxPQUFPLGVBQWU7QUFadEIsSUFBTSxtQ0FBbUM7QUFjekMsSUFBTyxzQkFBUSxhQUFhO0FBQUEsRUFDM0IsV0FBVztBQUFBLEVBQ1gsU0FBUztBQUFBLElBQ1IsWUFBWSxDQUFDLFFBQVEsT0FBTyxRQUFRLE9BQU87QUFBQSxJQUMzQyxPQUFPLENBQUM7QUFBQSxNQUNQLE1BQU07QUFBQSxNQUNOLGFBQWEsUUFBUSxrQ0FBVyxLQUFLO0FBQUEsSUFDdEMsQ0FBQztBQUFBLEVBQ0Y7QUFBQSxFQUNBLEtBQUs7QUFBQSxJQUNKLHFCQUFxQjtBQUFBLE1BQ3BCLE1BQU07QUFBQSxRQUNMLFlBQVk7QUFBQSxVQUNYLFdBQVc7QUFBQSxVQUNYLGNBQWM7QUFBQSxRQUNmO0FBQUEsTUFDRDtBQUFBLElBQ0Q7QUFBQSxFQUNEO0FBQUEsRUFDQSxTQUFTO0FBQUEsSUFDUixJQUFJO0FBQUEsTUFDSCxVQUFVO0FBQUEsUUFDVCxvQkFBb0I7QUFBQSxVQUNuQixpQkFBaUI7QUFBQSxRQUNsQjtBQUFBLE1BQ0Q7QUFBQSxJQUNELENBQUM7QUFBQSxJQUNELFVBQVU7QUFBQSxJQUNWLE9BQU87QUFBQSxNQUNOLFNBQVM7QUFBQSxJQUNWLENBQUM7QUFBQSxJQUNELGtCQUFrQjtBQUFBLElBQ2xCO0FBQUEsTUFDQyxNQUFNO0FBQUEsTUFDTixNQUFNLFVBQVUsTUFBTSxJQUFJO0FBQ3pCLFlBQUksQ0FBQyxHQUFHLE1BQU0sY0FBYyxFQUFHLFFBQU87QUFDdEMsZUFBTyxxQkFBcUIsTUFBTSxJQUFJO0FBQUEsVUFDckMsUUFBUTtBQUFBLFVBQ1IsS0FBSztBQUFBLFFBQ04sQ0FBQztBQUFBLE1BQ0Y7QUFBQSxJQUNEO0FBQUEsRUFDRDtBQUFBLEVBQ0EsY0FBYztBQUFBLElBQ2IsZ0JBQWdCO0FBQUEsTUFDZixRQUFRO0FBQUEsUUFDUCxPQUFPO0FBQUEsTUFDUjtBQUFBLElBQ0Q7QUFBQSxJQUNBLFNBQVMsQ0FBQyxXQUFXO0FBQUEsRUFDdEI7QUFDRCxDQUFDOyIsCiAgIm5hbWVzIjogW10KfQo=