feat:重构词典

This commit is contained in:
zyronon
2025-08-05 02:59:32 +08:00
parent 1be66fb5cf
commit d3278e581f
27 changed files with 679 additions and 20911 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

109
js_node/test2.js Normal file
View File

@@ -0,0 +1,109 @@
import fs from 'fs';
import path from 'path';
import {chromium} from 'playwright';
import pLimit from 'p-limit';
import {fileURLToPath} from 'url';
import dayjs from 'dayjs';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// 路径设置
const SOURCE_DIR = path.join(__dirname, 'source');
const RESULT_DIR = path.join(__dirname, 'result');
const TOTAL_RESULT_FILE = path.join(__dirname, 'save', 'all.json');
const TOTAL_RESULT_FILE2 = path.join(__dirname, 'save', 'all2.json');
const FAILED_FILE = path.join(__dirname, 'save', 'failed.json');
// 控制参数
const CONCURRENCY = 6;
let failList = []
// 创建结果目录
if (!fs.existsSync(RESULT_DIR)) {
fs.mkdirSync(RESULT_DIR);
}
const existingMap = new Map();
// 加载已爬数据(增量去重)
if (fs.existsSync(TOTAL_RESULT_FILE)) {
const lines = fs.readFileSync(TOTAL_RESULT_FILE, 'utf-8').split('\n').filter(Boolean);
console.log(lines.length)
for (const line of lines) {
try {
const obj = JSON.parse(line);
if (obj?.word) {
existingMap.set(obj.word.toLowerCase(), {...obj, id: existingMap.size});
}
} catch {
}
}
console.log(`📦 已加载 ${existingMap.size} 个已爬词`);
}
const failStr = fs.readFileSync(FAILED_FILE, 'utf-8')
if (failStr) {
failList = JSON.parse(failStr)
}
(async () => {
const files = fs.readdirSync(SOURCE_DIR).filter(f => f.endsWith('.json'));
for (const file of files) {
const filePath = path.join(SOURCE_DIR, file);
const raw = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
let result = [];
raw.forEach(obj => {
try {
let key = String(obj.name).toLowerCase();
let r = existingMap.get(key)
if (r) {
result.push({...r, word: String(r.word)});
} else {
try {
// console.log(`不存在:`, key)
let d = {
id: existingMap.size,
"word": String(obj.name),
"phonetic0": "",
"phonetic1": "",
"trans": [],
"sentences": [],
"phrases": [],
"synos": [],
"relWords": {"root": "", "rels": []},
"etymology": [],
}
if (Array.isArray(obj.trans)) {
d.trans = obj?.trans?.map((a) => ({pos: '', cn: a})) || []
} else {
d.trans = [{pos: '', cn: d.trans}]
}
existingMap.set(key, d);
result.push(d);
} catch (e) {
console.log('filePath:' + filePath, 'word:' + obj.name)
console.error(e);
}
}
} catch (e) {
console.log('--------filePath:' + filePath, 'word:' + JSON.stringify(obj));
console.error(e);
}
})
const outputName = path.basename(file, '.json') + '_v2.json';
const outputPath = path.join(RESULT_DIR, outputName);
fs.writeFileSync(outputPath, JSON.stringify(result, null, 2), 'utf-8');
// console.log(`✅ 已保存:${outputName}`);
}
console.log(`最终${existingMap.size}个单词`);
fs.writeFileSync(TOTAL_RESULT_FILE2, JSON.stringify(Array.from(existingMap), null, 2), 'utf-8');
console.log('\n🎉 所有任务完成!');
})();

104
js_node/汇总.js Normal file
View File

@@ -0,0 +1,104 @@
import fs from 'fs';
import path from 'path';
import {chromium} from 'playwright';
import pLimit from 'p-limit';
import {fileURLToPath} from 'url';
import dayjs from 'dayjs';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// 路径设置
const SOURCE_DIR = path.join(__dirname, 'source');
const SAVE_DIR = path.join(__dirname, 'save');
const TOTAL_RESULT_FILE = path.join(__dirname, 'save', 'all.json');
const existingMap = new Map();
// 加载已爬数据(增量去重)
if (fs.existsSync(TOTAL_RESULT_FILE)) {
const lines = fs.readFileSync(TOTAL_RESULT_FILE, 'utf-8').split('\n').filter(Boolean);
console.log(lines.length)
for (const line of lines) {
try {
const obj = JSON.parse(line);
if (obj?.word) {
existingMap.set(obj.word, obj);
}
} catch {
}
}
console.log(`📦 已加载 ${existingMap.size} 个已爬词`);
}
let normalList = new Map();
let unnormalList = new Map();
const safeString = (str) => (typeof str === 'string' ? str.trim() : '');
const safeSplit = (str, sep) =>
safeString(str) ? safeString(str).split(sep).filter(Boolean) : [];
function getTrans(trans) {
return safeSplit(trans, '\n').map(line => {
const match = line.match(/^([^\s.]+\.?)\s*(.*)$/);
if (match) {
let pos = safeString(match[1]);
let cn = safeString(match[2]);
// 如果 pos 不是常规词性(不以字母开头),例如 "【名】"
if (!/^[a-zA-Z]+\.?$/.test(pos)) {
cn = safeString(line); // 整行放到 cn
pos = ''; // pos 置空
}
return {pos, cn};
}
return {pos: '', cn: safeString(line)};
});
}
(async () => {
const files = fs.readdirSync(SOURCE_DIR).filter(f => f.endsWith('.json'));
for (const file of files) {
const filePath = path.join(SOURCE_DIR, file);
const raw = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
raw.filter(v => v && v.name && String(v.name).trim()).map(v => {
let word = String(v.name)
word = word.trim()
if (word.endsWith('.')) {
word = word.substring(0, word.length - 1);
}
let r = existingMap.get(word)
if (!r) {
r = {
"word": String(word),
"phonetic0": v?.ukphone?.replaceAll('[', '')?.replaceAll(']', '') || '',
"phonetic1": v?.usphone?.replaceAll('[', '')?.replaceAll(']', '') || '',
"trans": [],
"sentences": [],
"phrases": [],
"synos": [],
"relWords": {"root": "", "rels": []},
"etymology": [],
}
if (Array.isArray(v.trans)) {
r.trans = getTrans(v.trans.filter(a => a && a.length < 150).slice(0, 3).join('\n'));
} else {
r.trans = v.trans ? getTrans(v.trans) : [];
}
if (word.includes('/') || word.includes(' ') || word.includes('(') || word.includes(')') || word.includes('') || word.includes('')) {
unnormalList.set(word, r)
} else {
normalList.set(word, r)
}
}
})
}
console.log(normalList.size, unnormalList.size)
fs.writeFileSync(path.join(SAVE_DIR, 'normalList.json'), JSON.stringify(Array.from(normalList.values()), null, 2), 'utf-8');
fs.writeFileSync(path.join(SAVE_DIR, 'unnormalList.json'), JSON.stringify(Array.from(unnormalList.values()), null, 2), 'utf-8');
})();

198
js_node/爬虫.js Normal file
View File

@@ -0,0 +1,198 @@
import fs from 'fs';
import path from 'path';
import {chromium} from 'playwright';
import pLimit from 'p-limit';
import {fileURLToPath} from 'url';
import dayjs from 'dayjs';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// 路径设置
const SOURCE_DIR = path.join(__dirname, 'source');
const RESULT_DIR = path.join(__dirname, 'result');
const TOTAL_RESULT_FILE = path.join(__dirname, 'save', 'all.json');
const normalList_FILE = path.join(__dirname, 'save', 'normalList.json');
const unnormalList_FILE = path.join(__dirname, 'save', 'unnormalList.json');
const FAILED_FILE = path.join(__dirname, 'save', 'failed.json');
// 控制参数
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const MAX_COUNT = 999999999999;
let failList = []
let crawlCount = 0;
const existingMap = new Map();
// 创建结果目录
if (!fs.existsSync(RESULT_DIR)) {
fs.mkdirSync(RESULT_DIR);
}
// 加载已爬数据(增量去重)
if (fs.existsSync(TOTAL_RESULT_FILE)) {
const lines = fs.readFileSync(TOTAL_RESULT_FILE, 'utf-8').split('\n').filter(Boolean);
for (const line of lines) {
try {
const obj = JSON.parse(line);
if (obj?.word) {
existingMap.set(obj.word.toLowerCase(), obj);
}
} catch {
}
}
console.log(`📦 已加载 ${existingMap.size} 个已爬词`);
}
const failStr = fs.readFileSync(FAILED_FILE, 'utf-8')
if (failStr) {
failList = JSON.parse(failStr)
}
function addToFail(val) {
if (!failList.find(v => v.word === val.word)) {
failList.push(val);
fs.writeFileSync(FAILED_FILE, JSON.stringify(failList, null, 2), 'utf-8');
}
}
// 爬虫主函数
async function crawlWord(val, page, retry = 0, failName) {
let word = val.word
const data = val
const url = `https://www.youdao.com/result?word=${encodeURIComponent(word)}&lang=en`;
try {
await page.goto(url, {waitUntil: 'networkidle', timeout: 15000});
const titleEl = await page.locator('.title').first();
data.word = await titleEl.evaluate(el => el.firstChild?.nodeValue || '');
const phones = await page.$$('.per-phone .phonetic');
if (phones[0]) data.phonetic0 = (await phones[0].textContent())?.trim() || '';
if (phones[1]) data.phonetic1 = (await phones[1].textContent())?.trim() || '';
data.phonetic0 = data.phonetic0.replaceAll('/', '').trim()
data.phonetic1 = data.phonetic1.replaceAll('/', '').trim()
for (const el of await page.$$('.basic .word-exp')) {
const pos = await el.$('.pos');
const tran = await el.$('.trans');
data.trans.push({
pos: pos ? (await pos.textContent())?.trim() : '',
cn: tran ? (await tran.textContent())?.trim() : '',
});
}
for (const el of await page.$$('.blng_sents_part .trans-container ul li .col2')) {
const en = await el.$('.sen-eng');
const ch = await el.$('.sen-ch');
data.sentences.push({
c: en ? (await en.textContent())?.trim() : '',
cn: ch ? (await ch.textContent())?.trim() : '',
});
}
for (const el of await page.$$('.phrs ul li .phrs-content')) {
const point = await el.$('.point');
const tran = await el.$('.phr_trans');
data.phrases.push({
c: point ? (await point.textContent())?.trim() : '',
cn: tran ? (await tran.textContent())?.trim() : '',
});
}
try {
await page.getByText('同近义词', {timeout: 2000}).click();
await page.waitForSelector('.syno', {timeout: 3000});
for (const el of await page.$$('.syno-item')) {
const pos = await el.$('.index');
const tran = await el.$('.synptran');
const wordEl = await el.$('.clickable');
let str = wordEl ? (await wordEl.textContent())?.trim() : '';
data.synos.push({
pos: pos ? (await pos.textContent())?.trim() : '',
cn: tran ? (await tran.textContent())?.trim() : '',
ws: str.split('/').map(s => s.trim()).filter(Boolean),
});
}
} catch {
}
try {
await page.getByText('同根词', {timeout: 2000}).click();
await page.waitForSelector('.rel_word', {timeout: 3000});
const cigen = await page.$('.trans-container > p .point');
data.relWords.root = cigen ? (await cigen.textContent())?.trim() : '';
for (const el of await page.$$('.rel_word_item')) {
let item = {pos: '', words: []};
const pos = await el.$('.pos');
item.pos = pos ? (await pos.textContent())?.trim() : '';
for (const el2 of await el.$$('.rel_content p')) {
const word = await el2.$('.point');
let wordStr = word ? (await word.textContent())?.trim() : '';
let str = el2 ? (await el2.textContent())?.trim() : '';
str = str.replace(wordStr, '');
item.words.push({c: wordStr, cn: str});
}
data.relWords.rels.push(item);
}
} catch {
}
try {
await page.getByText('词源', {timeout: 2000}).click();
await page.waitForSelector('.etymology', {timeout: 3000});
for (const el of await page.$$('.trans-cell')) {
const header = await el.$('.header');
const zh_result = await el.$('.zh_result');
data.etymology.push({
t: header ? (await header.textContent())?.trim() : '',
d: zh_result ? (await zh_result.textContent())?.trim() : '',
});
}
} catch {
}
return data;
} catch (err) {
return data;
if (retry < 2) {
console.log(`🔁 ${word} 抓取失败,重试中...`);
await sleep(1000);
return crawlWord(val, page, retry + 1, failName);
} else {
console.log(`${word} 抓取失败`);
addToFail(val)
return data;
}
}
}
(async () => {
const browser = await chromium.launch({headless: true});
const page = browser.newPage()
async function start(file) {
const raw = JSON.parse(fs.readFileSync(file, 'utf-8'));
const resultMap = new Map();
for (let i = 0; i < MAX_COUNT; i++) {
let word = raw[i];
console.log(`爬取:${file}${word.word},进度:${resultMap.size} / ${raw.length};时间:${dayjs().format('YYYY-MM-DD HH:mm:ss')}`)
const result = await crawlWord(word, page, 0, file);
if (result) {
resultMap.set(word.word, result);
fs.writeFileSync(file.replaceAll('.json', '-fetch.json'), JSON.stringify(Array.from(resultMap.values()), null, 2), 'utf-8');
}
await sleep(2300);
}
}
await start(unnormalList_FILE)
await start(normalList_FILE)
await browser.close();
console.log('\n🎉 所有任务完成!');
})();

View File

@@ -1,4 +1,4 @@
import {Article, ArticleWord, DictType, getDefaultArticleWord, Sentence} from "@/types.ts";
import {Article, ArticleWord, getDefaultArticleWord, Sentence} from "@/types.ts";
import {cloneDeep} from "@/utils";
import nlp from "compromise/one";
import {usePlayWordAudio} from "@/hooks/sound.ts";
@@ -526,12 +526,6 @@ export function splitCNArticle2(text: string): string {
return s
}
export function isArticle(type: DictType): boolean {
return [
DictType.article,
].includes(type)
}
export function getTranslateText(article: Article) {
return article.textTranslate
.split('\n\n').filter(v => v)

View File

@@ -1,5 +1,6 @@
import {Article, Word} from "@/types.ts";
import {Article, getDefaultArticle, Word} from "@/types.ts";
import {useBaseStore} from "@/stores/base.ts";
import {nanoid} from "nanoid";
export function useWordOptions() {
@@ -63,11 +64,12 @@ export function useArticleOptions() {
const store = useBaseStore()
function isArticleCollect(val: Article) {
return !!store.collectArticle.articles.find(v => v.title.toLowerCase() === val.title.toLowerCase())
return !!store.collectArticle.articles.find(v => v.id === val.id)
}
//todo 这里先收藏,再修改。收藏里面的未同步。单词也是一样的
function toggleArticleCollect(val: Article) {
let rIndex = store.collectArticle.articles.findIndex(v => v.title.toLowerCase() === val.title.toLowerCase())
let rIndex = store.collectArticle.articles.findIndex(v => v.id === val.id)
if (rIndex > -1) {
store.collectArticle.articles.splice(rIndex, 1)
} else {

View File

@@ -3,84 +3,59 @@ import {useBaseStore} from "@/stores/base.ts";
import {Icon} from '@iconify/vue'
import "vue-activity-calendar/style.css";
import {useRouter} from "vue-router";
import {enArticle} from "@/assets/dictionary.ts";
import BasePage from "@/pages/pc/components/BasePage.vue";
import {useNav} from "@/utils";
import {Dict, DictResource, getDefaultDict} from "@/types.ts";
import {cloneDeep} from "@/utils";
import {_getDictDataByUrl, useNav} from "@/utils";
import {DictResource, DictType, getDefaultDict} from "@/types.ts";
import {useRuntimeStore} from "@/stores/runtime.ts";
import {getArticleBookDataByUrl} from "@/utils/article.ts";
import BaseIcon from "@/components/BaseIcon.vue";
import Dialog from "@/pages/pc/components/dialog/Dialog.vue";
import Input from "@/pages/pc/components/Input.vue";
import {computed} from "vue";
import Book from "@/pages/pc/components/Book.vue";
import {ElMessage, ElProgress} from 'element-plus';
import BaseButton from "@/components/BaseButton.vue";
import PopConfirm from "@/pages/pc/components/PopConfirm.vue";
import {onMounted, watch} from "vue";
const {nav} = useNav()
const base = useBaseStore()
const router = useRouter()
const store = useBaseStore()
const router = useRouter()
const runtimeStore = useRuntimeStore()
let showAddChooseDialog = $ref(false)
let showSearchDialog = $ref(false)
let searchKey = $ref('')
onMounted(init)
watch(() => store.load, init)
async function getBookDetail(val: DictResource) {
let r = await getArticleBookDataByUrl(val)
runtimeStore.editDict = cloneDeep(r)
nav('book-detail')
}
async function getBookDetail2(val: Dict) {
if (!val.name) {
showSearchDialog = true
return
async function init() {
if (store.article.studyIndex >= 1) {
if (!store.sbook.custom && !store.sbook.articles.length) {
store.article.bookList[store.article.studyIndex] = await _getDictDataByUrl(store.sbook, DictType.article)
}
}
runtimeStore.editDict = cloneDeep(val)
nav('book-detail')
}
const searchList = computed(() => {
if (searchKey) {
return enArticle.filter(v => v.name.toLocaleLowerCase().includes(searchKey.toLocaleLowerCase()))
}
return []
})
function addBook() {
showAddChooseDialog = false
runtimeStore.editDict = getDefaultDict()
nav('book-detail', {isAdd: true})
}
function startStudy() {
if (!base.currentBook.name) {
showSearchDialog = true
if (base.sbook.id) {
if (!base.sbook.articles.length) {
return ElMessage.warning('没有文章可学习!')
}
nav('/study-article')
} else {
ElMessage.warning('请先选择一本书籍')
return
}
router.push('/study-article')
}
let isMultiple = $ref(false)
let selectIds = $ref([])
function handleBatchDel() {
selectIds.forEach(id => {
let r = store.word.bookList.findIndex(v => v.id === id)
let r = base.article.bookList.findIndex(v => v.id === id)
if (r !== -1) {
if (store.word.studyIndex === r) {
store.word.studyIndex = -1
if (base.article.studyIndex === r) {
base.article.studyIndex = -1
}
if (store.word.studyIndex > r) {
store.word.studyIndex--
if (base.article.studyIndex > r) {
base.article.studyIndex--
}
store.word.bookList.splice(r, 1)
base.article.bookList.splice(r, 1)
}
})
selectIds = []
@@ -96,10 +71,11 @@ function toggleSelect(item) {
}
}
async function goDictDetail(val: DictResource) {
async function goBookDetail(val: DictResource) {
runtimeStore.editDict = getDefaultDict(val)
nav('book-detail', {})
nav('book-detail')
}
</script>
<template>
@@ -108,10 +84,10 @@ async function goDictDetail(val: DictResource) {
<div class="flex justify-between items-center">
<div class="bg-third p-3 gap-4 rounded-md cursor-pointer flex items-center">
<span class="text-lg font-bold"
@click="getBookDetail2(base.currentBook)">{{
@click="goBookDetail(base.currentBook)">{{
base.currentBook.name || '请选择书籍开始学习'
}}</span>
<BaseIcon @click="showSearchDialog = true"
<BaseIcon @click="router.push('/book-list')"
:icon="base.currentBook.name ? 'gg:arrows-exchange':'fluent:add-20-filled'"/>
</div>
<BaseButton
@@ -137,7 +113,7 @@ async function goDictDetail(val: DictResource) {
<BaseIcon class="del" title="删除" icon="solar:trash-bin-minimalistic-linear"/>
</PopConfirm>
<div class="color-blue cursor-pointer" v-if="store.article.bookList.length > 1"
<div class="color-blue cursor-pointer" v-if="base.article.bookList.length > 1"
@click="isMultiple = !isMultiple; selectIds = []">{{ isMultiple ? '取消' : '管理书籍' }}
</div>
<div class="color-blue cursor-pointer" @click="nav('dict-detail', { isAdd: true })">创建个人书籍</div>
@@ -147,9 +123,9 @@ async function goDictDetail(val: DictResource) {
<Book :is-add="false" quantifier="篇" :item="item" :checked="selectIds.includes(item.id)"
@check="() => toggleSelect(item)"
:show-checkbox="isMultiple && j >= 1"
v-for="(item, j) in store.article.bookList"
@click="goDictDetail(item)"/>
<Book :is-add="true" @click="router.push('/dict-list')"/>
v-for="(item, j) in base.article.bookList"
@click="goBookDetail(item)"/>
<Book :is-add="true" @click="router.push('/book-list')"/>
</div>
</div>
</BasePage>

View File

@@ -15,6 +15,7 @@ import MiniDialog from "@/pages/pc/components/dialog/MiniDialog.vue";
import EditArticle2 from "@/pages/pc/article/components/EditArticle2.vue";
import BaseIcon from "@/components/BaseIcon.vue";
import {_nextTick} from "@/utils";
import {ElMessage} from "element-plus";
const emit = defineEmits<{
importData: [val: Event]

View File

@@ -5,12 +5,15 @@ import BackIcon from "@/pages/pc/components/BackIcon.vue";
import Empty from "@/components/Empty.vue";
import ArticleList from "@/pages/pc/components/list/ArticleList.vue";
import {useBaseStore} from "@/stores/base.ts";
import {Article, getDefaultArticle} from "@/types.ts";
import {Article, DictId, DictType, getDefaultArticle, getDefaultDict} from "@/types.ts";
import {useRuntimeStore} from "@/stores/runtime.ts";
import BaseButton from "@/components/BaseButton.vue";
import {useRoute, useRouter} from "vue-router";
import EditBook from "@/pages/pc/article/components/EditBook.vue";
import {computed, onMounted} from "vue";
import {_getDictDataByUrl} from "@/utils";
import BaseIcon from "@/components/BaseIcon.vue";
import {useArticleOptions} from "@/hooks/dict.ts";
const runtimeStore = useRuntimeStore()
const base = useBaseStore()
@@ -19,29 +22,21 @@ const route = useRoute()
let isEdit = $ref(false)
let isAdd = $ref(false)
let loading = $ref(false)
let studyLoading = $ref(false)
let article: Article = $ref(getDefaultArticle())
let chapterIndex = $ref(-1)
let selectArticle: Article = $ref(getDefaultArticle())
function handleCheckedChange(val) {
let rIndex = runtimeStore.editDict.articles.findIndex(v => v.id === val.item.id)
if (rIndex > -1) {
chapterIndex = rIndex
article = val.item
}
selectArticle = val.item
}
const activeId = $computed(() => {
return runtimeStore.editDict.articles?.[chapterIndex]?.id ?? ''
})
function addMyStudyList() {
let rIndex = base.article.bookList.findIndex(v => v.name === runtimeStore.editDict.name)
if (rIndex > -1) {
base.article.studyIndex = rIndex
} else {
base.article.bookList.push(runtimeStore.editDict)
base.article.studyIndex = base.article.bookList.length - 1
async function addMyStudyList() {
studyLoading = true
base.changeBook(runtimeStore.editDict)
studyLoading = false
if (route.query?.from) {
router.back()
}
router.back()
}
@@ -50,20 +45,42 @@ const showBookDetail = computed(() => {
return !(isAdd || isEdit);
})
onMounted(() => {
async function init() {
if (route.query?.isAdd) {
isAdd = true
}else {
runtimeStore.editDict = getDefaultDict()
} else {
if (!runtimeStore.editDict.id) {
router.push("/article")
await router.push("/article")
} else {
if (!runtimeStore.editDict.articles.length
&& !runtimeStore.editDict.custom
&& ![DictId.articleCollect].includes(runtimeStore.editDict.id)
) {
loading = true
let r = await _getDictDataByUrl(runtimeStore.editDict, DictType.article)
loading = false
runtimeStore.editDict = r
}
if (runtimeStore.editDict.articles.length) {
selectArticle = runtimeStore.editDict.articles[0]
}
}
}
})
}
onMounted(init)
function formClose() {
if (isEdit) isEdit = false
else router.back()
}
const {
isArticleCollect,
toggleArticleCollect
} = useArticleOptions()
</script>
<template>
@@ -75,7 +92,7 @@ function formClose() {
<div class="flex">
<BaseButton type="info" @click="isEdit = true">编辑</BaseButton>
<BaseButton type="info" @click="router.push('batch-edit-article')">文章管理</BaseButton>
<BaseButton @click="addMyStudyList">学习</BaseButton>
<BaseButton :loading="studyLoading" @click="addMyStudyList">学习</BaseButton>
</div>
</div>
<div class="text-lg ">介绍{{ runtimeStore.editDict.description }}</div>
@@ -84,26 +101,41 @@ function formClose() {
<div class="flex flex-1 overflow-hidden">
<div class="left flex-[2] scroll p-0">
<ArticleList
v-if="runtimeStore.editDict.articles.length"
v-if="runtimeStore.editDict.length"
@title="handleCheckedChange"
@click="handleCheckedChange"
:list="runtimeStore.editDict.articles"
:active-id="activeId">
:active-id="selectArticle.id">
<template v-slot:suffix="{item,index}">
<BaseIcon
v-if="!isArticleCollect(item)"
class="collect"
@click="toggleArticleCollect(item)"
title="收藏" icon="ph:star"/>
<BaseIcon
v-else
class="fill"
@click="toggleArticleCollect(item)"
title="取消收藏" icon="ph:star-fill"/>
</template>
</ArticleList>
<Empty v-else/>
</div>
<div class="right flex-[4] shrink-0 pl-4 overflow-auto">
<div v-if="chapterIndex>-1">
<div v-if="selectArticle.id">
<div class="en-article-family title text-xl">
<div class="text-center text-2xl">{{ article.title }}</div>
<div class="text-2xl" v-if="article.text">
<div class="my-5" v-for="t in article.text.split('\n\n')">{{ t }}</div>
<div class="text-center text-2xl">
<audio :src="selectArticle.audioSrc" controls></audio>
</div>
<div class="text-center text-2xl">{{ selectArticle.title }}</div>
<div class="text-2xl" v-if="selectArticle.text">
<div class="my-5" v-for="t in selectArticle.text.split('\n\n')">{{ t }}</div>
</div>
</div>
<div class="mt-2">
<div class="text-center text-2xl">{{ article.titleTranslate }}</div>
<div class="text-xl" v-if="article.textTranslate">
<div class="my-5" v-for="t in article.textTranslate.split('\n\n')">{{ t }}</div>
<div class="text-center text-2xl">{{ selectArticle.titleTranslate }}</div>
<div class="text-xl" v-if="selectArticle.textTranslate">
<div class="my-5" v-for="t in selectArticle.textTranslate.split('\n\n')">{{ t }}</div>
</div>
<Empty v-else/>
</div>
@@ -116,9 +148,7 @@ function formClose() {
<div class="card mb-0 h-[95vh]" v-else>
<div class="flex justify-between items-center relative">
<BackIcon class="z-2" @click="isAdd ? $router.back():(isEdit = false)"/>
<div class="absolute text-2xl text-align-center w-full">{{
runtimeStore.editDict.id ? '修改' : '创建'
}}书籍
<div class="absolute text-2xl text-align-center w-full">{{ runtimeStore.editDict.id ? '修改' : '创建' }}书籍
</div>
</div>
<div class="center">

View File

@@ -0,0 +1,86 @@
<script setup lang="ts">
import "vue-activity-calendar/style.css";
import {useNav} from "@/utils";
import BasePage from "@/pages/pc/components/BasePage.vue";
import {DictResource, getDefaultDict} from "@/types.ts";
import {useRuntimeStore} from "@/stores/runtime.ts";
import BaseIcon from "@/components/BaseIcon.vue";
import Empty from "@/components/Empty.vue";
import Input from "@/pages/pc/components/Input.vue";
import BaseButton from "@/components/BaseButton.vue";
import DictList from "@/pages/pc/components/list/DictList.vue";
import BackIcon from "@/pages/pc/components/BackIcon.vue";
import {useRouter} from "vue-router";
import {enArticle} from "@/assets/dictionary.ts";
import {computed} from "vue";
const {nav} = useNav()
const runtimeStore = useRuntimeStore()
const router = useRouter()
function selectDict(e) {
console.log(e.dict)
getDictDetail(e.dict)
}
async function getDictDetail(val: DictResource) {
runtimeStore.editDict = getDefaultDict(val)
nav('book-detail', {from: 'list'})
}
let showSearchInput = $ref(false)
let searchKey = $ref('')
const searchList = computed<any[]>(() => {
if (searchKey) {
let s = searchKey.toLowerCase()
return enArticle.filter((item) => {
return item.id.toLowerCase().includes(s)
|| item.name.toLowerCase().includes(s)
|| item.category.toLowerCase().includes(s)
|| item.tags.join('').replace('所有', '').toLowerCase().includes(s)
|| item?.url?.toLowerCase?.().includes?.(s)
})
}
return []
})
</script>
<template>
<BasePage>
<div class="card">
<div class="flex items-center relative gap-2">
<BackIcon class="z-2" @Click='router.back()'/>
<div class="flex flex-1 gap-4" v-if="showSearchInput">
<Input placeholder="请输入书籍名称/缩写/类别" v-model="searchKey" class="flex-1" autofocus/>
<BaseButton @click="showSearchInput = false, searchKey = ''">取消</BaseButton>
</div>
<div class="py-1 flex flex-1 justify-end" v-else>
<span class="page-title absolute w-full center">书籍列表</span>
<BaseIcon @click="showSearchInput = true"
class="z-1"
icon="fluent:search-24-regular"/>
</div>
</div>
<div class="mt-4" v-if="searchKey">
<DictList
v-if="searchList.length "
@selectDict="selectDict"
:list="searchList"
:select-id="'-1'"/>
<Empty v-else text="没有相关书籍"/>
</div>
<div class="w-full mt-2" v-else>
<DictList
v-if="enArticle.length "
@selectDict="selectDict"
:list="enArticle"
:select-id="'-1'"/>
</div>
</div>
</BasePage>
</template>
<style scoped lang="scss">
</style>

View File

@@ -9,7 +9,7 @@ import {genArticleSectionData, splitCNArticle2, splitEnArticle2, usePlaySentence
import {_nextTick, _parseLRC, cloneDeep, last} from "@/utils";
import {watch} from "vue";
import Empty from "@/components/Empty.vue";
import {ElInputNumber, ElOption, ElPopover, ElSelect, ElUpload, UploadProps} from "element-plus";
import {ElInputNumber, ElMessage, ElOption, ElPopover, ElSelect, ElUpload, UploadProps} from "element-plus";
import * as Comparison from "string-comparison"
import BaseIcon from "@/components/BaseIcon.vue";
import Dialog from "@/pages/pc/components/dialog/Dialog.vue";

View File

@@ -1,310 +0,0 @@
<script setup lang="ts">
import {onMounted, onUnmounted} from "vue";
import {Article, getDefaultArticle} from "@/types.ts";
import BaseButton from "@/components/BaseButton.vue";
import {cloneDeep} from "@/utils";
import {useBaseStore} from "@/stores/base.ts";
import List from "@/pages/pc/components/list/List.vue";
import Dialog from "@/pages/pc/components/dialog/Dialog.vue";
import {emitter, EventKey} from "@/utils/eventBus.ts";
import {useDisableEventListener, useWindowClick} from "@/hooks/event.ts";
import {MessageBox} from "@/utils/MessageBox.tsx";
import {useRuntimeStore} from "@/stores/runtime.ts";
import {nanoid} from "nanoid";
import MiniDialog from "@/pages/pc/components/dialog/MiniDialog.vue";
import EditArticle2 from "@/pages/pc/article/components/EditArticle2.vue";
import {_nextTick} from "@/utils";
const emit = defineEmits<{
importData: [val: Event]
exportData: [val: string]
}>()
const base = useBaseStore()
const runtimeStore = useRuntimeStore()
let article = $ref<Article>(getDefaultArticle())
let show = $ref(false)
let editArticleRef: any = $ref()
let listEl: any = $ref()
onMounted(() => {
emitter.on(EventKey.openArticleListModal, (val: Article) => {
console.log('val', val)
show = true
if (val) {
article = cloneDeep(val)
}
})
})
onUnmounted(() => {
emitter.off(EventKey.openArticleListModal)
})
useDisableEventListener(() => show)
async function selectArticle(item: Article) {
let r = await checkDataChange()
if (r) {
article = cloneDeep(item)
}
}
function checkDataChange() {
return new Promise(resolve => {
let editArticle: Article = editArticleRef.getEditArticle()
if (editArticle.id !== '-1') {
editArticle.title = editArticle.title.trim()
editArticle.titleTranslate = editArticle.titleTranslate.trim()
editArticle.text = editArticle.text.trim()
editArticle.textTranslate = editArticle.textTranslate.trim()
if (
editArticle.title !== article.title ||
editArticle.titleTranslate !== article.titleTranslate ||
editArticle.text !== article.text ||
editArticle.textTranslate !== article.textTranslate
) {
return MessageBox.confirm(
'检测到数据有变动,是否保存?',
'提示',
async () => {
let r = await editArticleRef.save('save')
if (r) resolve(true)
},
() => resolve(true),
)
}
} else {
if (editArticle.title.trim() && editArticle.text.trim()) {
return MessageBox.confirm(
'检测到数据有变动,是否保存?',
'提示',
async () => {
let r = await editArticleRef.save('save')
if (r) resolve(true)
},
() => resolve(true),
)
}
}
resolve(true)
})
}
async function add() {
let r = await checkDataChange()
if (r) {
article = getDefaultArticle()
}
}
function saveArticle(val: Article): boolean {
console.log('saveArticle', val)
if (val.id) {
let rIndex = runtimeStore.editDict.articles.findIndex(v => v.id === val.id)
if (rIndex > -1) {
runtimeStore.editDict.articles[rIndex] = cloneDeep(val)
}
} else {
let has = runtimeStore.editDict.articles.find((item: Article) => item.title === val.title)
if (has) {
ElMessage.error('已存在同名文章!')
return false
}
val.id = nanoid(6)
runtimeStore.editDict.articles.push(val)
setTimeout(() => {
listEl.scrollBottom()
})
}
article = cloneDeep(val)
//TODO 保存完成后滚动到对应位置
ElMessage.success('保存成功!')
syncBookInMyStudyList()
return true
}
//todo 考虑与syncDictInMyStudyList、changeDict方法合并
function syncBookInMyStudyList(study = false) {
_nextTick(() => {
let rIndex = base.article.bookList.findIndex(v => v.id === runtimeStore.editDict.id)
let temp = cloneDeep(runtimeStore.editDict);
console.log(temp)
temp.custom = true
temp.length = temp.articles.length
if (rIndex > -1) {
base.article.bookList[rIndex] = temp
if (study) base.article.studyIndex = rIndex
} else {
base.article.bookList.push(temp)
if (study) base.article.studyIndex = base.article.bookList.length - 1
}
}, 100)
}
function saveAndNext(val: Article) {
if (saveArticle(val)) {
add()
}
}
let showExport = $ref(false)
useWindowClick(() => showExport = false)
</script>
<template>
<Dialog
v-model="show"
:full-screen="true"
:header="false"
>
<div class="add-article">
<div class="aslide">
<header>
<div class="dict-name">{{ runtimeStore.editDict.name }}</div>
</header>
<List
ref="listEl"
v-model:list="runtimeStore.editDict.articles"
:select-item="article"
@del-select-item="article = getDefaultArticle()"
@select-item="selectArticle"
>
<template v-slot="{item,index}">
<div class="name"> {{ `${index + 1}. ${item.title}` }}</div>
<div class="translate-name"> {{ ` ${item.titleTranslate}` }}</div>
</template>
</List>
<div class="add" v-if="!article.title">
正在添加新文章...
</div>
<div class="footer">
<div class="import">
<BaseButton size="small">导入</BaseButton>
<input type="file"
accept=".csv, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet, application/vnd.ms-excel"
@change="e => emit('importData',e)">
</div>
<div class="export"
style="position: relative"
@click.stop="null">
<BaseButton size="small" @click="showExport = true">导出</BaseButton>
<MiniDialog
v-model="showExport"
style="width: 80rem;bottom: calc(100% + 10rem);top:unset;"
>
<div class="mini-row-title">
导出选项
</div>
<div class="mini-row">
<BaseButton size="small" @click="emit('exportData',{type:'all',data:[]})">全部文章</BaseButton>
</div>
<div class="mini-row">
<BaseButton size="small" @click="emit('exportData',{type:'chapter',data:article})">当前章节</BaseButton>
</div>
</MiniDialog>
</div>
<BaseButton size="small" @click="add">新增</BaseButton>
</div>
</div>
<EditArticle2
ref="editArticleRef"
type="batch"
@save="saveArticle"
@saveAndNext="saveAndNext"
:article="article"/>
</div>
</Dialog>
</template>
<style scoped lang="scss">
.add-article {
//position: fixed;
position: relative;
left: 0;
top: 0;
z-index: 9;
width: 100%;
height: 100%;
box-sizing: border-box;
color: var(--color-font-1);
background: var(--color-second);
display: flex;
.close {
position: absolute;
right: 1.2rem;
top: 1.2rem;
}
.aslide {
width: 14vw;
height: 100%;
padding: 0 .6rem;
display: flex;
flex-direction: column;
$height: 4rem;
header {
height: $height;
display: flex;
justify-content: space-between;
align-items: center;
//opacity: 0;
.dict-name {
font-size: 2rem;
color: var(--color-font-1);
}
}
.name {
font-size: 1.1rem;
}
.translate-name {
font-size: 1rem;
}
.add {
width: 16rem;
box-sizing: border-box;
border-radius: .5rem;
margin-bottom: .6rem;
padding: .6rem;
display: flex;
justify-content: space-between;
transition: all .3s;
color: var(--color-font-1);
background: var(--color-item-active);
}
.footer {
height: $height;
display: flex;
gap: .6rem;
align-items: center;
justify-content: flex-end;
.import {
display: inline-flex;
position: relative;
input {
position: absolute;
height: 100%;
width: 100%;
opacity: 0;
}
}
}
}
}
</style>

View File

@@ -3,7 +3,7 @@
import {Dict, DictType, getDefaultDict} from "@/types.ts";
import {cloneDeep} from "@/utils";
import {ElForm,ElFormItem,ElInput,ElSelect,ElOption, FormInstance, FormRules} from "element-plus";
import {ElForm, ElFormItem, ElInput, ElSelect, ElOption, FormInstance, FormRules, ElMessage} from "element-plus";
import {onMounted, reactive} from "vue";
import {useRuntimeStore} from "@/stores/runtime.ts";
import {useBaseStore} from "@/stores/base.ts";

View File

@@ -1,10 +1,10 @@
<script setup lang="ts">
import {Dict} from "@/types.ts";
import {Dict, DictResource} from "@/types.ts";
import {Icon} from "@iconify/vue";
import {ElProgress, ElCheckbox} from 'element-plus';
const props = defineProps<{
item?: Dict
item?: Partial<Dict>;
quantifier?: string
isAdd: boolean
showCheckbox?: boolean
@@ -16,7 +16,7 @@ defineEmits<{
}>()
const progress = $computed(() => {
if (props.item.complete) return 100
if (props.item?.complete) return 100
return Number(((props.item?.lastLearnIndex / props.item?.length) * 100).toFixed())
})

View File

@@ -322,7 +322,7 @@ $header-height: 4rem;
.content {
width: 25rem;
color: var(--color-font-1);
color: var(--color-main-text);
padding: .2rem 1.6rem 1.6rem;
}
}

View File

@@ -3,7 +3,7 @@ import {Dict} from "@/types.ts";
import Book from "@/pages/pc/components/Book.vue";
defineProps<{
list?: Dict[],
list?: Partial<Dict>[],
selectId?: string
}>()

View File

@@ -3,22 +3,21 @@ import type {Word} from "@/types";
import {DictId, getDefaultDict} from "@/types";
import BasePage from "@/pages/pc/components/BasePage.vue";
import {computed, onMounted, reactive} from "vue";
import {computed, onMounted, reactive, shallowReactive} from "vue";
import {useRuntimeStore} from "@/stores/runtime.ts";
import {assign, cloneDeep} from "@/utils";
import {_getDictDataByUrl, _nextTick, cloneDeep, convertToWord} from "@/utils";
import {nanoid} from "nanoid";
import BaseIcon from "@/components/BaseIcon.vue";
import BaseTable from "@/pages/pc/components/BaseTable.vue";
import WordItem from "@/pages/pc/components/WordItem.vue";
import type {FormInstance, FormRules} from "element-plus";
import {ElForm, ElFormItem, ElInput, ElMessage} from "element-plus";
import PopConfirm from "@/pages/pc/components/PopConfirm.vue";
import BackIcon from "@/pages/pc/components/BackIcon.vue";
import BaseButton from "@/components/BaseButton.vue";
import {useRoute, useRouter} from "vue-router";
import {useBaseStore} from "@/stores/base.ts";
import EditBook from "@/pages/pc/article/components/EditBook.vue";
import {_getDictDataByUrl, _nextTick, convertToWord} from "@/utils";
import {ElForm, ElFormItem, ElInput, ElMessage} from "element-plus";
const runtimeStore = useRuntimeStore()
const base = useBaseStore()
@@ -32,7 +31,7 @@ let list = $computed({
return runtimeStore.editDict.words
},
set(v) {
runtimeStore.editDict.words = v
runtimeStore.editDict.words = shallowReactive(v)
}
})
@@ -89,7 +88,7 @@ async function onSubmitWord() {
if (data.id) {
let r = list.find(v => v.id === data.id)
if (r) {
assign(r, data)
Object.assign(r, data)
ElMessage.success('修改成功')
} else {
ElMessage.success('修改失败,未找到单词')
@@ -335,13 +334,13 @@ defineRender(() => {
</ElFormItem>
</ElForm>
<div class="center">
<base-button
<BaseButton
type="info"
onClick={closeWordForm}>关闭
</base-button>
<base-button type="primary"
onClick={onSubmitWord}>保存
</base-button>
</BaseButton>
<BaseButton type="primary"
onClick={onSubmitWord}>保存
</BaseButton>
</div>
</div>
) : null

View File

@@ -62,7 +62,8 @@ const searchList = computed<any[]>(() => {
if (searchKey) {
let s = searchKey.toLowerCase()
return dictionaryResources.filter((item) => {
return item.name.toLowerCase().includes(s)
return item.id.toLowerCase().includes(s)
|| item.name.toLowerCase().includes(s)
|| item.category.toLowerCase().includes(s)
|| item.tags.join('').replace('所有', '').toLowerCase().includes(s)
|| item?.url?.toLowerCase?.().includes?.(s)

View File

@@ -14,6 +14,7 @@ import BookDetail from "@/pages/pc/article/BookDetail.vue";
import BatchEditArticlePage from "@/pages/pc/article/BatchEditArticlePage.vue";
import DictList from "@/pages/pc/word/DictList.vue";
import Setting from "@/pages/pc/Setting.vue";
import BookList from "@/pages/pc/article/BookList.vue";
export const routes: RouteRecordRaw[] = [
{
@@ -25,28 +26,16 @@ export const routes: RouteRecordRaw[] = [
{path: 'dict-list', component: DictList},
{path: 'study-word', component: StudyWord},
{path: 'dict-detail', component: DictDetail},
{path: 'article', component: ArticleHomePage},
{path: 'study-article', component: StudyArticle},
{path: 'edit-article', component: EditArticlePage},
{path: 'batch-edit-article', component: BatchEditArticlePage},
{path: 'book-detail', component: BookDetail},
{path: 'book-list', component: BookList},
{path: 'setting', component: Setting},
]
},
// {path: '/mobile', component: Mobile,},
// {path: '/mobile/practice', component: MobilePractice},
// {path: '/mobile/dict-detail', component: DictDetail},
// {path: '/mobile/set-dict-plan', name: 'set-dict-plan', component: SetDictPlan},
// {path: '/mobile/setting', component: Setting},
// {path: '/mobile/music-setting', component: MusicSetting},
// {path: '/mobile/other-setting', component: OtherSetting},
// {path: '/mobile/data-manage', component: DataManage},
// {path: '/mobile/collect', component: CollectPage},
// {path: '/mobile/wrong', component: WrongPage},
// {path: '/mobile/simple', component: SimplePage},
// {path: '/mobile/about', component: About},
// {path: '/mobile/feedback', component: Feedback},
{path: '/test', component: Test},
// {path: '/', redirect: '/pc/practice'},
]

View File

@@ -88,7 +88,10 @@ export const useBaseStore = defineStore('base', {
return getDefaultDict()
},
sdict(): Dict {
return this.currentStudyWordDict
if (this.word.studyIndex >= 0) {
return this.word.bookList[this.word.studyIndex] ?? getDefaultDict()
}
return getDefaultDict()
},
currentStudyProgress(): number {
if (!this.sdict.words?.length) return 0
@@ -98,6 +101,9 @@ export const useBaseStore = defineStore('base', {
currentBook(): Dict {
return this.article.bookList[this.article.studyIndex] ?? {}
},
sbook(): Dict {
return this.article.bookList[this.article.studyIndex] ?? {}
},
currentBookProgress(): number {
if (this.currentBook.name) return Number(Number(this.currentBook.lastLearnIndex / this.currentBook.length).toFixed(2))
return 0
@@ -131,19 +137,6 @@ export const useBaseStore = defineStore('base', {
} catch (e) {
console.error('读取本地dict数据失败', e)
}
if (this.article.studyIndex >= 1) {
let current = this.article.bookList[this.article.studyIndex]
let dictResourceUrl = `./dicts/${current.language}/${current.type}/${current.translateLanguage}/${current.url}`;
if (!current.articles.length) {
let s = await getDictFile(dictResourceUrl)
current.articles = cloneDeep(s.map(v => {
v.id = nanoid(6)
return v
}))
}
// console.log('this.currentBook', this.currentBook.articles[0])
}
resolve(true)
})
},
@@ -168,5 +161,22 @@ export const useBaseStore = defineStore('base', {
this.word.studyIndex = this.word.bookList.length - 1
}
},
//改变书籍
changeBook(val: Dict) {
//把其他的书籍里面的文章数据都删掉,全保存在内存里太卡了
this.article.bookList.slice(1).map(v => {
if (!v.custom) {
v.articles = shallowReactive([])
}
})
let rIndex = this.article.bookList.findIndex((v: Dict) => v.id === val.id)
if (rIndex > -1) {
this.article.studyIndex = rIndex
this.article.bookList[this.article.studyIndex].articles = shallowReactive(val.articles)
} else {
this.article.bookList.push(getDefaultDict(val))
this.article.studyIndex = this.article.bookList.length - 1
}
},
},
})

View File

@@ -10,6 +10,7 @@ import axios from "axios";
import {env} from "@/config/ENV.ts";
import {nextTick} from "vue";
import {dictionaryResources, enArticle} from "@/assets/dictionary.ts";
import {ElMessage} from "element-plus";
export function no() {
ElMessage.warning('未现实')
@@ -224,7 +225,7 @@ export function shakeCommonDict(n: BaseState): BaseState {
if (!v.custom) v.words = []
})
data.article.bookList.map((v: Dict) => {
if (!v.custom) v.words = []
if (!v.custom) v.articles = []
})
return data
}
@@ -233,14 +234,14 @@ export function isMobile(): boolean {
return /Mobi|Android|iPhone/i.test(navigator.userAgent)
}
export function getDictFile(url: string) {
return new Promise<any[]>(async resolve => {
let r = await fetch(url).catch(r => {
console.log('getDictFile_error', r)
})
let v = await r.json()
resolve(v)
})
export async function getDictFile(url: string) {
try {
const r = await fetch(url);
return await r.json();
} catch (err) {
console.log('getDictFile_error', err);
return null;
}
}
export function useNav() {
@@ -391,17 +392,29 @@ export function _parseLRC(lrc: string): { start: number, end: number, text: stri
return parsed;
}
export async function _getDictDataByUrl(val: DictResource): Promise<Dict> {
export async function _getDictDataByUrl(val: DictResource, type: DictType = DictType.word): Promise<Dict> {
let dictResourceUrl = `./dicts/${val.language}/word/${val.url}`.replace('.json', '_v2.json');
if (type === DictType.article) {
dictResourceUrl = `./dicts/${val.language}/${val.type}/${val.url}`;
}
let s = await getDictFile(dictResourceUrl)
let words = cloneDeep(s.map(v => {
v.id = nanoid(6)
return v
}))
return getDefaultDict({
...val,
words
})
if (s) {
if (type === DictType.word) {
let words = cloneDeep(s.map(v => {
v.id = nanoid(6)
return v
}))
return getDefaultDict({...val, words})
} else {
let articles = cloneDeep(s.map(v => {
v.id = nanoid(6)
return v
}))
console.log('articles',articles)
return getDefaultDict({...val, articles})
}
}
return getDefaultDict()
}
//从字符串里面转换为Word格式
@@ -555,10 +568,6 @@ export function reverse<T>(array: T[]): T[] {
return array.slice().reverse();
}
export function assign<T extends object, U extends object>(target: T, ...sources: U[]): T & U {
return Object.assign(target, ...sources);
}
export function groupBy<T extends Record<string, any>>(array: T[], key: string) {
return array.reduce<Record<string, T[]>>((result, item) => {
const groupKey = String(item[key]);