fix: keydown优化

This commit is contained in:
old burden 2026-04-01 17:44:14 +08:00
parent a347873a4e
commit d2c1fff13f
5 changed files with 189 additions and 229 deletions

View File

@ -1030,6 +1030,10 @@ const onEditorKeyup = (e) => {
mentionActiveIndex.value = -1
return
}
// keydown keyup
if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Enter') {
return
}
mentionVisible.value = isReference.value && hasActiveMentionTrigger()
if (mentionVisible.value && e.key === '@' && mentionCandidates.value.length === 0) {
Message.warning('请等待上传完成后再引用')
@ -1037,7 +1041,15 @@ const onEditorKeyup = (e) => {
mentionActiveIndex.value = -1
return
}
mentionActiveIndex.value = mentionVisible.value && mentionCandidates.value.length ? 0 : -1
if (mentionVisible.value && mentionCandidates.value.length) {
const max = mentionCandidates.value.length - 1
mentionActiveIndex.value =
mentionActiveIndex.value >= 0
? Math.min(mentionActiveIndex.value, max)
: 0
} else {
mentionActiveIndex.value = -1
}
}
const selectMentionItem = (item) => {

View File

@ -22,15 +22,11 @@
<template v-else>
<a-menu-item :key="item.key">
<template #icon>
<a-image
:width="28"
:preview="false"
:height="28"
:src="`/images/nav/${
selectedKeys.indexOf(item.key) > -1
? item.icon + '-a'
: item.icon
}.png`" />
<span
class="menu-svg-icon"
:class="{ active: selectedKeys.indexOf(item.key) > -1 }"
v-html="getMenuSvg(item.key)"
></span>
</template>
<div class="menu-item">
{{ item.label }}
@ -148,6 +144,19 @@ const count = (system) => {
? servicePolicyCount.value
: agencyPolicyCount.value
}
const getMenuSvg = (key) => {
if (key === 'video-gen') {
return '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3a9 9 0 1 0 0 18a9 9 0 0 0 0-18Zm-1.5 5.2a.9.9 0 0 1 1.35-.78l4.7 2.7a.9.9 0 0 1 0 1.56l-4.7 2.7a.9.9 0 0 1-1.35-.78V8.2Z"/></svg>'
}
if (key === 'asset-group-manage') {
return '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 6.5A2.5 2.5 0 0 1 5.5 4H9l1.7 2H18.5A2.5 2.5 0 0 1 21 8.5v8A2.5 2.5 0 0 1 18.5 19h-13A2.5 2.5 0 0 1 3 16.5v-10Z"/></svg>'
}
if (key === 'asset-manage') {
return '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 4h14a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2Zm1.8 11.5h10.4L14 11.4l-2.6 3.1-1.7-2.1-2.9 3.1Zm2.7-6.1a1.6 1.6 0 1 0 0-3.2a1.6 1.6 0 0 0 0 3.2Z"/></svg>'
}
return '<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="8"/></svg>'
}
</script>
<style lang="less">
.arco-tooltip-content {
@ -174,4 +183,25 @@ const count = (system) => {
justify-content: space-between;
align-items: center;
}
.menu-svg-icon {
width: 22px;
height: 22px;
display: inline-flex;
align-items: center;
justify-content: center;
color: rgba(255, 255, 255, 0.74);
transition: color 0.2s ease;
}
.menu-svg-icon:deep(svg) {
width: 22px;
height: 22px;
display: block;
fill: currentColor;
}
.menu-svg-icon.active {
color: #ffffff;
}
</style>

View File

@ -1,34 +1,10 @@
<template>
<div class="asset-group-page">
<section class="ag-panel">
<h3 class="ag-title">新增资源组</h3>
<div class="ag-form">
<div class="ag-field">
<label>名称</label>
<a-input v-model="createForm.name" placeholder="请输入资源组名称(<=64字符" />
<div class="ag-head">
<h3 class="ag-title">资源组管理</h3>
<a-button type="primary" @click="openCreateDialog">新增资源组</a-button>
</div>
<div class="ag-field">
<label>描述</label>
<a-textarea
v-model="createForm.description"
:max-length="300"
show-word-limit
placeholder="请输入描述(<=300字符" />
</div>
<div class="ag-field">
<label>GroupType</label>
<a-select v-model="createForm.groupType" :disabled="true">
<a-option value="AIGC">AIGC</a-option>
</a-select>
</div>
<div class="ag-actions">
<a-button type="primary" :loading="createLoading" @click="createGroup">新增</a-button>
</div>
</div>
</section>
<section class="ag-panel">
<h3 class="ag-title">查询资源组</h3>
<div class="ag-filter">
<div class="ag-field">
<label>名称</label>
@ -121,6 +97,26 @@
<a-modal v-model:visible="detailVisible" title="资源组详情" :footer="false" width="680px">
<pre class="ag-detail">{{ prettyDetail }}</pre>
</a-modal>
<a-modal
v-model:visible="createVisible"
title="新增资源组"
:confirm-loading="createLoading"
@ok="createGroup">
<div class="ag-create-form">
<div class="ag-field">
<label>名称</label>
<a-input v-model="createForm.name" placeholder="请输入资源组名称(<=64字符" />
</div>
<div class="ag-field">
<label>描述</label>
<a-textarea
v-model="createForm.description"
:max-length="300"
show-word-limit
placeholder="请输入描述(<=300字符" />
</div>
</div>
</a-modal>
</div>
</template>
@ -130,6 +126,7 @@ export default {
data() {
return {
createLoading: false,
createVisible: false,
listLoading: false,
detailLoadingId: '',
detailVisible: false,
@ -161,6 +158,9 @@ export default {
this.search(1)
},
methods: {
openCreateDialog() {
this.createVisible = true
},
buildGroupIds() {
return String(this.filters.groupIdsText || '')
.split(',')
@ -189,6 +189,7 @@ export default {
this.$message.success('新增成功')
this.createForm.name = ''
this.createForm.description = ''
this.createVisible = false
this.search(1)
} else {
this.$message.error(res.msg || '新增失败')
@ -295,10 +296,16 @@ export default {
}
.ag-title {
margin: 0 0 14px;
margin: 0;
font-size: 15px;
font-weight: 600;
}
.ag-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 14px;
}
.ag-form,
.ag-filter {
@ -306,6 +313,11 @@ export default {
grid-template-columns: repeat(3, minmax(180px, 1fr));
gap: 12px;
}
.ag-create-form {
display: flex;
flex-direction: column;
gap: 12px;
}
.ag-field {
display: flex;

View File

@ -1,16 +1,10 @@
<template>
<div class="asset-manage-page">
<section class="asset-left">
<div class="asset-left-head">
<div class="panel-title">素材组树</div>
<a-button size="mini" type="outline" :loading="groupLoading" @click="loadGroups">刷新分组</a-button>
<div class="form-grid mtop">
<div class="field">
<label>素材组名称</label>
<a-input v-model="groupForm.name" placeholder="输入分组名称" />
</div>
<div class="field actions">
<a-button type="primary" size="mini" @click="createGroup">新建分组</a-button>
<a-button size="mini" @click="updateGroup" :disabled="!groupForm.id">更新分组</a-button>
<a-button size="mini" type="outline" :loading="groupLoading" @click="loadGroups">刷新分组</a-button>
</div>
</div>
<div class="group-tree">
@ -30,31 +24,10 @@
</section>
<section class="asset-right">
<div class="panel-title">新增素材</div>
<div class="form-grid">
<div class="field">
<label>GroupId</label>
<a-input v-model="createForm.groupId" placeholder="请选择左侧分组或手动输入" />
</div>
<div class="field">
<label>文件</label>
<input type="file" @change="onFileChange" />
<div class="group-id">{{ createForm.fileName || '未选择文件' }}</div>
</div>
<div class="field">
<label>Name</label>
<a-input v-model="createForm.name" placeholder="素材名称(可选)" />
</div>
<div class="field">
<label>AssetType</label>
<a-select v-model="createForm.assetType">
<a-option value="Image">Image</a-option>
<a-option value="Video">Video</a-option>
<a-option value="Audio">Audio</a-option>
</a-select>
</div>
<div class="asset-left-head">
<div class="panel-title">素材管理</div>
<div class="field actions">
<a-button type="primary" :loading="createLoading" @click="createAsset">新增素材</a-button>
<a-button type="primary" size="mini" @click="openCreateAssetDialog">新增素材</a-button>
</div>
</div>
@ -150,14 +123,44 @@
<a-modal v-model:visible="detailVisible" title="素材详情" :footer="false" width="700px">
<pre class="detail-box">{{ detailText }}</pre>
</a-modal>
<a-modal
v-model:visible="createAssetVisible"
title="新增素材"
:confirm-loading="createLoading"
@ok="createAsset">
<div class="create-form-vertical">
<div class="field">
<label>素材组</label>
<a-select v-model="createForm.groupId" placeholder="请选择素材组">
<a-option v-for="g in groups" :key="g.Id || g.id" :value="String(g.Id || g.id)">
{{ g.Name || g.name || (g.Id || g.id) }}
</a-option>
</a-select>
</div>
<div class="field">
<label>上传素材</label>
<input type="file" @change="onFileChange" />
<div class="group-id">{{ createForm.fileName || '未选择文件' }}</div>
</div>
<div class="field">
<label>素材名称</label>
<a-input v-model="createForm.name" placeholder="素材名称(可选)" />
</div>
<div class="field">
<label>类型</label>
<a-select v-model="createForm.assetType">
<a-option value="Image">图片</a-option>
<a-option value="Video">视频</a-option>
<a-option value="Audio">音频</a-option>
</a-select>
</div>
</div>
</a-modal>
</div>
</template>
<script>
const GROUP_LIST_API = 'api/byteAssetGroup/listAssetGroups'
const GROUP_CREATE_API = 'api/byteAssetGroup/createAssetGroup'
const GROUP_GET_API = 'api/byteAssetGroup/getAssetGroup'
const GROUP_UPDATE_API = 'api/byteAssetGroup/updateAssetGroup'
const ASSET_CREATE_API = 'api/byteAsset/createAsset'
const ASSET_LIST_API = 'api/byteAsset/listAssets'
const ASSET_GET_API = 'api/byteAsset/getAsset'
@ -170,6 +173,7 @@ export default {
groupLoading: false,
createLoading: false,
listLoading: false,
createAssetVisible: false,
groups: [],
selectedGroupId: '',
createForm: {
@ -179,10 +183,6 @@ export default {
name: '',
assetType: 'Image'
},
groupForm: {
name: '',
id: ''
},
filters: {
groupId: '',
name: '',
@ -207,6 +207,15 @@ export default {
this.loadGroups()
},
methods: {
async openCreateAssetDialog() {
await this.loadGroups()
this.createForm.groupId = this.selectedGroupId || this.createForm.groupId || ''
this.createForm.file = null
this.createForm.fileName = ''
this.createForm.name = ''
this.createForm.assetType = 'Image'
this.createAssetVisible = true
},
async loadGroups() {
this.groupLoading = true
try {
@ -225,8 +234,6 @@ export default {
if (!this.selectedGroupId && this.groups.length) {
const gid = this.groups[0].Id || this.groups[0].id
this.selectedGroupId = gid
this.groupForm.id = gid
this.groupForm.name = this.groups[0].Name || this.groups[0].name || ''
this.createForm.groupId = gid
this.filters.groupId = gid
this.searchAssets(1)
@ -240,64 +247,10 @@ export default {
selectGroup(g) {
const gid = g?.Id || g?.id
this.selectedGroupId = gid
this.groupForm.id = gid
this.groupForm.name = g?.Name || g?.name || ''
this.createForm.groupId = gid
this.filters.groupId = gid
this.searchAssets(1)
},
async createGroup() {
const name = String(this.groupForm.name || '').trim()
if (!name) return this.$message.error('请输入素材组名称')
try {
const res = await this.$axios({
url: GROUP_CREATE_API,
method: 'POST',
data: { Name: name, ProjectName: 'default' }
})
if (res.code === 200) {
this.$message.success('创建素材组成功')
await this.loadGroups()
} else {
this.$message.error(res.msg || '创建素材组失败')
}
} catch (e) {
this.$message.error(e?.message || '创建素材组失败')
}
},
async updateGroup() {
const id = String(this.groupForm.id || '').trim()
const name = String(this.groupForm.name || '').trim()
if (!id) return this.$message.error('请先选择素材组')
if (!name) return this.$message.error('请输入素材组名称')
try {
const detailRes = await this.$axios({
url: GROUP_GET_API,
method: 'POST',
data: { Id: id }
})
const detail = detailRes?.data || {}
const payload = {
Id: id,
Name: name,
GroupType: detail.GroupType || detail.groupType || 'AIGC',
ProjectName: detail.ProjectName || detail.projectName || 'default'
}
const res = await this.$axios({
url: GROUP_UPDATE_API,
method: 'POST',
data: payload
})
if (res.code === 200) {
this.$message.success('更新素材组成功')
await this.loadGroups()
} else {
this.$message.error(res.msg || '更新素材组失败')
}
} catch (e) {
this.$message.error(e?.message || '更新素材组失败')
}
},
onFileChange(e) {
const file = e?.target?.files?.[0]
this.createForm.file = file || null
@ -324,6 +277,7 @@ export default {
this.createForm.file = null
this.createForm.fileName = ''
this.createForm.name = ''
this.createAssetVisible = false
this.searchAssets(1)
} else {
this.$message.error(res.msg || '新增素材失败')
@ -457,6 +411,12 @@ export default {
font-weight: 600;
margin-bottom: 10px;
}
.asset-left-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
}
.group-tree {
margin-top: 10px;
display: flex;
@ -512,6 +472,11 @@ export default {
flex-direction: row;
align-items: center;
}
.create-form-vertical {
display: flex;
flex-direction: column;
gap: 12px;
}
.mtop {
margin-top: 14px;
}

View File

@ -6,35 +6,21 @@ import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.MapperFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.PropertyNamingStrategies;
import com.ruoyi.ai.mapper.AiUserMapper;
import com.ruoyi.common.core.domain.entity.AiUser;
import com.ruoyi.common.core.domain.entity.SysDept;
import com.ruoyi.common.core.domain.entity.SysUser;
import com.ruoyi.common.core.domain.model.LoginUser;
import com.ruoyi.ai.service.IByteDeptApiKeyService;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.common.utils.http.OkHttpUtils;
import com.ruoyi.system.mapper.SysDeptMapper;
import com.ruoyi.system.mapper.SysUserMapper;
import lombok.extern.slf4j.Slf4j;
import okhttp3.MediaType;
import okhttp3.Request;
import okhttp3.RequestBody;
import okhttp3.Response;
import org.apache.catalina.User;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import java.io.IOException;
@Slf4j
@Service
public class BaseByteApiService {
@Resource
private SysDeptMapper deptMapper;
@Resource
private AiUserMapper userMapper;
protected final ObjectMapper objectMapper = new ObjectMapper()
.setSerializationInclusion(JsonInclude.Include.NON_NULL)
.setPropertyNamingStrategy(PropertyNamingStrategies.UPPER_CAMEL_CASE)
@ -46,12 +32,13 @@ public class BaseByteApiService {
@Value("${byteapi.url}")
protected String API_URL;
protected String DEPT_ANCESTORS_SPLIT = ",";
@Autowired
protected IByteDeptApiKeyService byteDeptApiKeyService;
/**
* 执行 POST成功则返回响应体字符串可能为空字符串
* POST JSON 调用方舟 OpenAPI若响应含 {@code Result} 节点则解析为业务对象 api.docx 返回示例一致
*/
private String doPost(String path, Object request) throws IOException {
protected <T> T httpExecute(String path, Object request, Class<T> clz) throws IOException {
String jsonBody = objectMapper.writeValueAsString(request);
RequestBody body = RequestBody.create(
MediaType.parse("application/json; charset=utf-8"),
@ -59,7 +46,7 @@ public class BaseByteApiService {
Request httpRequest = new Request.Builder()
.url(API_URL + path)
.header("Content-Type", "application/json")
.header("Authorization", "Bearer " + getUserDeptApiKey())
.header("Authorization", "Bearer " + resolveCurrentAiUserApiKey())
.post(body)
.build();
try (Response response = OkHttpUtils.newCall(httpRequest).execute()) {
@ -68,20 +55,9 @@ public class BaseByteApiService {
throw new RuntimeException("execute error" + errorMsg);
}
if (response.body() == null) {
return "";
}
return response.body().string();
}
}
/**
* POST JSON 调用方舟 OpenAPI若响应含 {@code Result} 节点则解析为业务对象 api.docx 返回示例一致
*/
protected <T> T httpExecute(String path, Object request, Class<T> clz) throws IOException {
String responseBody = doPost(path, request);
if (responseBody == null || responseBody.isEmpty()) {
throw new RuntimeException("response body null");
}
String responseBody = response.body().string();
JsonNode root = objectMapper.readTree(responseBody);
JsonNode result = root.get("Result");
if (result != null && !result.isNull()) {
@ -89,75 +65,40 @@ public class BaseByteApiService {
}
return objectMapper.readValue(responseBody, clz);
}
}
/**
* 无业务体返回的 OpenAPI 调用 DeleteAsset
*/
protected void httpExecuteNoContent(String path, Object request) throws IOException {
doPost(path, request);
String jsonBody = objectMapper.writeValueAsString(request);
RequestBody body = RequestBody.create(
MediaType.parse("application/json; charset=utf-8"),
jsonBody);
Request httpRequest = new Request.Builder()
.url(API_URL + path)
.header("Content-Type", "application/json")
.header("Authorization", "Bearer " + resolveCurrentAiUserApiKey())
.post(body)
.build();
try (Response response = OkHttpUtils.newCall(httpRequest).execute()) {
if (!response.isSuccessful()) {
String errorMsg = response.body() != null ? response.body().string() : "execute error";
throw new RuntimeException("execute error" + errorMsg);
}
}
}
/**
* 根据用户找到对应的project
*/
protected String getUserProject() {
Long userId = SecurityUtils.getAiUserId();
if (userId == null) {
return null;
}
AiUser user = userMapper.selectAiUserById(userId);
if (user == null) {
return null;
}
// 第二层部门IDAPI_KEY放在这里
Long secondLvDeptId = getSecondLevelDept(user.getDeptId());
if (secondLvDeptId == null) {
return null;
}
SysDept secondDept = deptMapper.selectDeptById(secondLvDeptId);
return secondDept.getProject();
// TODO
return "default";
}
/**
* 根据用户所在部门找到对应的火山方舟ApiKey
*/
protected String getUserDeptApiKey() {
Long userId = SecurityUtils.getAiUserId();
if (userId == null) {
return null;
}
AiUser user = userMapper.selectAiUserById(userId);
if (user == null) {
return null;
}
// 第二层部门IDAPI_KEY放在这里
Long secondLvDeptId = getSecondLevelDept(user.getDeptId());
if (secondLvDeptId == null) {
return null;
}
SysDept secondDept = deptMapper.selectDeptById(secondLvDeptId);
return secondDept.getByteApiKey();
}
/**
* 找到当前部门所属第二部门ID
* @param deptId 当前部门
*/
protected Long getSecondLevelDept(long deptId) {
SysDept dept = deptMapper.selectDeptById(deptId);
String ancestors = dept.getAncestors();
// 判断是第几层
if (ancestors == null || ancestors.isEmpty() || "0".equals(ancestors)) {
// 第一层
return null;
}
String[] parentDeptArray = ancestors.split(DEPT_ANCESTORS_SPLIT);
int length = parentDeptArray.length;
if (length == 1) {
// 只有一个上级所以当前节点是第二层直接返回
return deptId;
}
// 大于二级
return Long.parseLong(parentDeptArray[1]);
protected String resolveCurrentAiUserApiKey() {
return byteDeptApiKeyService.resolveVolcApiKey(SecurityUtils.getAiUserId());
}
}