fix(frontend): TagInput 体质优化与多分隔符支持 + 后端 saveOrIgnore 加固

TagInput:
- cut prop 支持字符串或字符串数组,多分隔符任一匹配即切;
  v-model 写回用第一个分隔符 (joinChar) 拼接保持向后兼容
- 修复 max 仅在 cut 分隔符路径生效,回车/blur/paste 路径未查 max 的 bug
- 修复 data() 初始化与 watch(value) 用硬编码 ',' 切分,
  统一走 joinChar (单一输出分隔符) 还原 v-model,避免含空格的列名
  (如项目模板 'Sprint 1') 被切碎
- 修复 watch(value) 空值/null 时不清空 disSource 的潜在残留数据
- v-for 加 :key='text',配合初始化去重避免拖拽错位
- 重复 tag 由静默忽略改为提示「该标签已存在」
- 编辑 tag 加 dedup 检查,重命名成已有 tag 文本时阻止关闭
- 粘贴改为按 cut 切分逐个 addTag;addTag 返回 boolean,
  pasteText 在 max 满时 break 短路
- splitByCuts/parseValue 拆分:回切用 joinChar,paste/输入用 cutPattern
- 拼接翻译 '最多只能添加X个' 改为 $L('最多只能添加(*)个', max);
  新增「最多只能添加(*)个」「该标签已存在」到 language/original-web.txt

manage.vue:
- 创建项目任务列表启用 :cut=\"[',', ',', ' ']\",支持半角/全角逗号/空格

IME (compositionstart/end 时序不可靠,统一走 @on-keydown + W3C 标准 229 守卫):
- login/ProjectPanel/TaskAdd/UserTagsModal 的 onXxxKeydown 加
  isComposing || key=='Process' || keyCode==229 三重守卫,
  与 TagInput.downEnter 风格统一
- file.vue 块模式重命名 onKeydown 用 \$nextTick 包 onEnter,
  避免 keydown 早于 input 事件 commit v-model 导致少一个字符

AbstractModel.performInsertOrIgnore:
- \$uniqueBy 不为 null 时抛 InvalidArgumentException;
  MySQL INSERT IGNORE 无法按指定列 scope 冲突,与框架 ON CONFLICT 语义不一致
- lastInsertId 仅在 > 0 时回填主键;
  避免在无 auto_increment 列的表上把业务设置的 PK 覆盖为 0

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
kuaifan 2026-06-15 01:26:55 +00:00
parent 3f5078ec9b
commit 27c65cc582
9 changed files with 107 additions and 53 deletions

View File

@ -217,6 +217,12 @@ class AbstractModel extends Model
*/
protected function performInsertOrIgnore(Builder $query, array|string|null $uniqueBy)
{
// MySQL INSERT IGNORE 无法按指定列限制冲突范围,所有 unique 冲突一并吞掉。
// 若调用方传了 $uniqueBy 期望精确 scope这里直接抛错避免与框架语义偷偷不一致。
if ($uniqueBy !== null) {
throw new \InvalidArgumentException('saveOrIgnore $uniqueBy is not supported on MySQL driver; pass null.');
}
if ($this->usesUniqueIds()) {
$this->setUniqueIds();
}
@ -240,10 +246,12 @@ class AbstractModel extends Model
}
if ($this->getIncrementing()) {
$this->setAttribute(
$this->getKeyName(),
$query->getConnection()->getPdo()->lastInsertId()
);
$lastId = $query->getConnection()->getPdo()->lastInsertId();
// 无 auto_increment 列的表上 INSERT IGNORE 即使插入成功 lastInsertId 也返回 "0"
// 别用它去覆盖业务设置的主键。
if ($lastId > 0) {
$this->setAttribute($this->getKeyName(), $lastId);
}
}
$this->exists = true;

View File

@ -2467,3 +2467,5 @@ AI任务分析
页面已切换,引导已结束
步骤执行失败
操作引导启动失败
最多只能添加(*)个
该标签已存在

View File

@ -6,7 +6,7 @@
tag="ul"
draggable=".column-item"
>
<div class="tags-item column-item" v-for="(text, index) in disSource">
<div class="tags-item column-item" v-for="(text, index) in disSource" :key="text">
<span class="tags-content" @click.stop="edit(disSource,index)">{{text}}</span><span class="tags-del" @click.stop="delTag(index)">&times;</span>
</div>
</Draggable>
@ -60,14 +60,6 @@
},
},
data() {
const disSource = [];
if( this.value ){
this.value?.split(",").forEach(item => {
if (item) {
disSource.push(item)
}
});
}
return {
minWidth: 80,
@ -78,7 +70,7 @@
content: '',
disSource,
disSource: this.parseValue(this.value),
isFocus: false,
@ -103,25 +95,10 @@
this.wayMinWidth();
},
value(val) {
if( val && typeof val == 'string' ){
let disSource = [];
val?.split(",").forEach(item => {
if (item) {
disSource.push(item)
}
});
this.disSource = disSource;
}
this.disSource = this.parseValue(val);
},
disSource(val) {
let temp = '';
val.forEach(item => {
if (temp != '') {
temp += this.cut;
}
temp += item;
});
this.$emit('input', temp);
this.$emit('input', val.join(this.joinChar()));
this.$emit('on-change');
}
},
@ -134,6 +111,43 @@
}
},
methods: {
normalizedCuts() {
const raw = Array.isArray(this.cut) ? this.cut : [this.cut];
return raw.filter(c => typeof c === 'string' && c.length > 0);
},
cutPattern() {
const cuts = this.normalizedCuts();
if (cuts.length === 0) {
return null;
}
const escaped = cuts.map(c => c.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
return new RegExp(escaped.join('|'), 'g');
},
joinChar() {
return this.normalizedCuts()[0] || ',';
},
parseValue(val) {
const list = [];
if (typeof val !== 'string' || val === '') {
return list;
}
val.split(this.joinChar()).forEach(item => {
const value = item.trim();
if (value && list.indexOf(value) === -1) {
list.push(value);
}
});
return list;
},
splitByCuts(str) {
const pattern = this.cutPattern();
return pattern ? str.split(pattern) : [str];
},
showTis(msg) {
this.tis = msg;
clearTimeout(this.tisTimeout);
this.tisTimeout = setTimeout(() => { this.tis = ''; }, 2000);
},
edit(disSource,index){
this.editData.disSource = disSource
this.editData.index = index
@ -144,12 +158,17 @@
okText: "确定",
value: disSource[index] + '',
onOk: (desc) => {
if (!desc) {
const trimmed = (desc || '').trim()
if (!trimmed) {
return `请输入名称`
}
this.editData.name = desc
this.editData.disSource[this.editData.index] = desc
this.$set(this.disSource,this.editData.index,desc)
const exists = this.disSource.indexOf(trimmed)
if (exists !== -1 && exists !== this.editData.index) {
return `该标签已存在`
}
this.editData.name = trimmed
this.editData.disSource[this.editData.index] = trimmed
this.$set(this.disSource, this.editData.index, trimmed)
return false
},
});
@ -192,7 +211,14 @@
pasteText(e) {
e.preventDefault();
let content = (e.clipboardData || window.clipboardData).getData('text');
this.addTag(false, content)
if (!content) {
return;
}
for (const item of this.splitByCuts(content)) {
const value = item.trim();
if (!value) continue;
if (this.addTag(false, value) === false) break;
}
},
downEnter(e) {
if (e.isComposing || e.key === 'Process' || e.keyCode === 229) {
@ -220,32 +246,45 @@
this.$emit("on-blur", e)
},
onKeyup(e) {
if (e.keyCode !== 13) {
this.addTag(e, this.content);
}
this.$emit("on-keyup", e)
},
addTag(e, content) {
if (e === false || e.keyCode === 13) {
if (content.trim() != '' && this.disSource.indexOf(content.trim()) === -1) {
this.disSource.push(content.trim());
const isForce = e === false || e.keyCode === 13;
let value;
if (isForce) {
value = content.trim();
} else {
if (content === '') return true;
let matchedCut = null;
for (const c of this.normalizedCuts()) {
if (c.length <= content.length && content.substring(content.length - c.length) === c) {
matchedCut = c;
break;
}
}
if (matchedCut === null) return true;
value = content.substring(0, content.length - matchedCut.length).trim();
}
if (value === '') {
this.content = '';
return;
return true;
}
if (this.max > 0 && this.disSource.length >= this.max) {
this.content = '';
this.tis = '最多只能添加' + this.max + '个';
clearTimeout(this.tisTimeout);
this.tisTimeout = setTimeout(() => { this.tis = ''; }, 2000);
return;
this.showTis(this.$L('最多只能添加(*)个', this.max));
return false;
}
let temp = content.trim();
let cutPos = temp.length - this.cut.length;
if (temp != '' && temp.substring(cutPos) === this.cut) {
temp = temp.substring(0, cutPos);
if (temp.trim() != '' && this.disSource.indexOf(temp.trim()) === -1) {
this.disSource.push(temp.trim());
}
if (this.disSource.indexOf(value) !== -1) {
this.content = '';
this.showTis(this.$L('该标签已存在'));
return true;
}
this.disSource.push(value);
this.content = '';
return true;
},
delTag(index) {
if (index === false) {

View File

@ -502,6 +502,7 @@ export default {
},
onLoginKeydown(e) {
if (e.isComposing || e.key === 'Process' || e.keyCode === 229) return;
if (e.keyCode === 13) {
this.onLogin();
}

View File

@ -323,7 +323,7 @@
</div>
</FormItem>
<FormItem v-if="addData.columns" :label="$L('任务列表')">
<TagInput v-model="addData.columns"/>
<TagInput v-model="addData.columns" :cut="[',', '', ' ']"/>
</FormItem>
<FormItem v-else :label="$L('项目模板')">
<Select :value="0" @on-change="selectChange" :placeholder="$L('请选择模板')">

View File

@ -1374,6 +1374,7 @@ export default {
},
onAddColumnKeydown(e) {
if (e.isComposing || e.key === 'Process' || e.keyCode === 229) return;
if (e.keyCode === 13) {
this.addColumnSubmit();
}

View File

@ -470,6 +470,7 @@ export default {
},
onSubNameKeydown(e) {
if (e.isComposing || e.key === 'Process' || e.keyCode === 229) return;
if (e.keyCode === 13) {
this.addSubTask();
}

View File

@ -224,11 +224,13 @@ export default {
});
},
onAddKeydown(e) {
if (e.isComposing || e.key === 'Process' || e.keyCode === 229) return;
if (e.keyCode === 13) {
this.handleAdd();
}
},
onEditKeydown(e, tag) {
if (e.isComposing || e.key === 'Process' || e.keyCode === 229) return;
if (e.keyCode === 13) {
this.confirmEdit(tag);
}

View File

@ -1991,7 +1991,7 @@ export default {
onKeydown(e, item) {
if (e.keyCode === 13) {
this.onEnter(item);
this.$nextTick(() => this.onEnter(item));
} else if (e.keyCode === 27) {
const isCreate = !/^\d+$/.test(item.id);
if (isCreate) {