From 5c27e1dc6b12b44a64dc06b828463a1537ea5c39 Mon Sep 17 00:00:00 2001 From: aooiuu Date: Sun, 19 May 2024 19:53:05 +0800 Subject: [PATCH] feat(desktop): epub --- .eslintignore | 1 + main.code-workspace | 6 + packages/epub/README.md | 3 + packages/epub/epub.d.ts | 96 + packages/epub/index.js | 776 ++++++ packages/epub/package.json | 28 + packages/shared/package.json | 2 +- packages/shared/src/localBookManager.ts | 160 +- packages/shared/src/start.ts | 0 packages/web/electron/api.ts | 6 +- packages/web/electron/config.ts | 19 + packages/web/electron/main.ts | 2 +- packages/web/package.json | 13 +- packages/web/src/api/index.ts | 17 + packages/web/src/assets/main.scss | 26 +- .../Setting/components/ReadPage/index.vue | 28 + .../Setting/components/SettingRow/index.vue | 12 + packages/web/src/components/Setting/index.vue | 27 + packages/web/src/main.ts | 2 + packages/web/src/pages/pc/books/index.vue | 2 +- packages/web/src/pages/pc/category/index.vue | 2 +- packages/web/src/pages/pc/content/index.vue | 15 +- packages/web/src/pages/pc/layout/index.vue | 36 +- packages/web/src/pages/rule-info/index.vue | 2 +- packages/web/src/stores/setting.ts | 56 + pnpm-lock.yaml | 2380 ++++++++++------- pnpm-workspace.yaml | 2 +- 27 files changed, 2711 insertions(+), 1008 deletions(-) create mode 100644 packages/epub/README.md create mode 100644 packages/epub/epub.d.ts create mode 100644 packages/epub/index.js create mode 100644 packages/epub/package.json create mode 100644 packages/shared/src/start.ts create mode 100644 packages/web/electron/config.ts create mode 100644 packages/web/src/components/Setting/components/ReadPage/index.vue create mode 100644 packages/web/src/components/Setting/components/SettingRow/index.vue create mode 100644 packages/web/src/components/Setting/index.vue create mode 100644 packages/web/src/stores/setting.ts diff --git a/.eslintignore b/.eslintignore index 5bd03380..9fa09d32 100644 --- a/.eslintignore +++ b/.eslintignore @@ -2,3 +2,4 @@ node_modules *.d.ts /docs /dist +packages/epub/ diff --git a/main.code-workspace b/main.code-workspace index 4dea3018..79dd064a 100644 --- a/main.code-workspace +++ b/main.code-workspace @@ -13,6 +13,12 @@ { "path": "./packages/web" }, + { + "path": "./packages/server" + }, + { + "path": "./packages/shared" + }, { "path": "./docs" } diff --git a/packages/epub/README.md b/packages/epub/README.md new file mode 100644 index 00000000..6465828b --- /dev/null +++ b/packages/epub/README.md @@ -0,0 +1,3 @@ +# README + +[epub ](https://github.com/julien-c/epub) \ No newline at end of file diff --git a/packages/epub/epub.d.ts b/packages/epub/epub.d.ts new file mode 100644 index 00000000..98fbcef3 --- /dev/null +++ b/packages/epub/epub.d.ts @@ -0,0 +1,96 @@ +// Type definitions for epub +// Project: https://github.com/julien-c/epub +// Definitions by: Julien Chaumond +// Definitions: https://github.com/DefinitelyTyped/DefinitelyTyped + +/// + +/** + * new EPub(fname[, imageroot][, linkroot]) + * - fname (String): filename for the ebook + * - imageroot (String): URL prefix for images + * - linkroot (String): URL prefix for links + * + * Creates an Event Emitter type object for parsing epub files + * + * var epub = new EPub("book.epub"); + * epub.on("end", function () { + * console.log(epub.spine); + * }); + * epub.on("error", function (error) { ... }); + * epub.parse(); + * + * Image and link URL format is: + * + * imageroot + img_id + img_zip_path + * + * So an image "logo.jpg" which resides in "OPT/" in the zip archive + * and is listed in the manifest with id "logo_img" will have the + * following url (providing that imageroot is "/images/"): + * + * /images/logo_img/OPT/logo.jpg + **/ +import { EventEmitter } from 'events' + +declare class EPub extends EventEmitter { + constructor(epubfile: string, imagewebroot?: string, chapterwebroot?: string) + + metadata: EPub.Metadata + manifest: Object + spine: { + toc: { href: string; id: string } + contents: Array + } + flow: Array + toc: Array + + parse(options?: EPub.parseOptions): void + + getChapter( + chapterId: string, + callback: (error: Error, text: string) => void + ): void + + getChapterRaw( + chapterId: string, + callback: (error: Error, text: string) => void + ): void + + getImage( + id: string, + callback: (error: Error, data: Buffer, mimeType: string) => void + ): void + + getFile( + id: string, + callback: (error: Error, data: Buffer, mimeType: string) => void + ): void + + hasDRM(): boolean +} + +export = EPub + +declare namespace EPub { + export interface TocElement { + level: number + order: number + title: string + id: string + href: string + } + + export interface Metadata { + creator: string + creatorFileAs: string + title: string + language: string + subject: string + date: string + description: string + } + + export interface parseOptions { + xml2jsOptions: object + } +} \ No newline at end of file diff --git a/packages/epub/index.js b/packages/epub/index.js new file mode 100644 index 00000000..15934176 --- /dev/null +++ b/packages/epub/index.js @@ -0,0 +1,776 @@ +// @ts-nocheck + +const EventEmitter = require('node:events').EventEmitter +const xml2js = require('xml2js') + +const xml2jsOptions = xml2js.defaults['0.1'] + +// Mock zipfile using pure-JS adm-zip: +const AdmZip = require('adm-zip') + +const ZipFile = function (filename) { + this.admZip = new AdmZip(filename) + this.names = this.admZip.getEntries().map((zipEntry) => { + return zipEntry.entryName + }) + this.count = this.names.length +} +ZipFile.prototype.readFile = function (name, cb) { + this.admZip.readFileAsync(this.admZip.getEntry(name), (buffer, error) => { + // `error` is bogus right now, so let's just drop it. + // see https://github.com/cthackers/adm-zip/pull/88 + return cb(null, buffer) + }) +} + +// TODO: Cache parsed data + +/** + * new EPub(fname[, imageroot][, linkroot]) + * - fname (String): filename for the ebook + * - imageroot (String): URL prefix for images + * - linkroot (String): URL prefix for links + * + * Creates an Event Emitter type object for parsing epub files + * + * var epub = new EPub("book.epub"); + * epub.on("end", function () { + * console.log(epub.spine); + * }); + * epub.on("error", function (error) { ... }); + * epub.parse(); + * + * Image and link URL format is: + * + * imageroot + img_id + img_zip_path + * + * So an image "logo.jpg" which resides in "OPT/" in the zip archive + * and is listed in the manifest with id "logo_img" will have the + * following url (providing that imageroot is "/images/"): + * + * /images/logo_img/OPT/logo.jpg + **/ +class EPub extends EventEmitter { + constructor(fname, imageroot, linkroot) { + super() + + this.filename = fname + + this.imageroot = (imageroot || '/images/').trim() + this.linkroot = (linkroot || '/links/').trim() + + if (this.imageroot.substr(-1) != '/') + this.imageroot += '/' + + if (this.linkroot.substr(-1) != '/') + this.linkroot += '/' + } + + /** + * EPub#parse(options) -> undefined + * - options (object): An optional options object to override xml2jsOptions + * Starts the parser, needs to be called by the script + **/ + parse(options = {}) { + Object.assign(xml2jsOptions, options.xml2jsOptions) + + this.containerFile = false + this.mimeFile = false + this.rootFile = false + + this.metadata = {} + this.manifest = {} + this.guide = [] + this.spine = { toc: false, contents: [] } + this.flow = [] + this.toc = [] + + this.open() + } + + /** + * EPub#open() -> undefined + * + * Opens the epub file with Zip unpacker, retrieves file listing + * and runs mime type check + **/ + open() { + try { + this.zip = new ZipFile(this.filename) + } + catch (E) { + this.emit('error', new Error('Invalid/missing file')) + return + } + + if (!this.zip.names || !this.zip.names.length) { + this.emit('error', new Error('No files in archive')) + return + } + + this.checkMimeType() + } + + /** + * EPub#checkMimeType() -> undefined + * + * Checks if there's a file called "mimetype" and that it's contents + * are "application/epub+zip". On success runs root file check. + **/ + checkMimeType() { + let i, len + + for (i = 0, len = this.zip.names.length; i < len; i++) { + if (this.zip.names[i].toLowerCase() == 'mimetype') { + this.mimeFile = this.zip.names[i] + break + } + } + if (!this.mimeFile) { + this.emit('error', new Error('No mimetype file in archive')) + return + } + this.zip.readFile(this.mimeFile, (err, data) => { + if (err) { + this.emit('error', new Error('Reading archive failed')) + return + } + const txt = data.toString('utf-8').toLowerCase().trim() + + if (txt != 'application/epub+zip') { + this.emit('error', new Error('Unsupported mime type')) + return + } + + this.getRootFiles() + }) + } + + /** + * EPub#getRootFiles() -> undefined + * + * Looks for a "meta-inf/container.xml" file and searches for a + * rootfile element with mime type "application/oebps-package+xml". + * On success calls the rootfile parser + **/ + getRootFiles() { + let i, len + for (i = 0, len = this.zip.names.length; i < len; i++) { + if (this.zip.names[i].toLowerCase() == 'meta-inf/container.xml') { + this.containerFile = this.zip.names[i] + break + } + } + if (!this.containerFile) { + this.emit('error', new Error('No container file in archive')) + return + } + + this.zip.readFile(this.containerFile, (err, data) => { + if (err) { + this.emit('error', new Error('Reading archive failed')) + return + } + const xml = data.toString('utf-8').toLowerCase().trim() + const xmlparser = new xml2js.Parser(xml2jsOptions) + + xmlparser.on('end', (result) => { + if (!result.rootfiles || !result.rootfiles.rootfile) { + this.emit('error', new Error('No rootfiles found')) + console.dir(result) + return + } + + const rootfile = result.rootfiles.rootfile + let filename = false; let i; let len + + if (Array.isArray(rootfile)) { + for (i = 0, len = rootfile.length; i < len; i++) { + if (rootfile[i]['@']['media-type'] + && rootfile[i]['@']['media-type'] == 'application/oebps-package+xml' + && rootfile[i]['@']['full-path']) { + filename = rootfile[i]['@']['full-path'].toLowerCase().trim() + break + } + } + } + else if (rootfile['@']) { + if (rootfile['@']['media-type'] != 'application/oebps-package+xml' || !rootfile['@']['full-path']) { + this.emit('error', new Error('Rootfile in unknown format')) + return + } + filename = rootfile['@']['full-path'].toLowerCase().trim() + } + + if (!filename) { + this.emit('error', new Error('Empty rootfile')) + return + } + + for (i = 0, len = this.zip.names.length; i < len; i++) { + if (this.zip.names[i].toLowerCase() == filename) { + this.rootFile = this.zip.names[i] + break + } + } + + if (!this.rootFile) { + this.emit('error', new Error('Rootfile not found from archive')) + return + } + + this.handleRootFile() + }) + + xmlparser.on('error', (err) => { + this.emit('error', new Error(`Parsing container XML failed in getRootFiles: ${err.message}`)) + }) + + xmlparser.parseString(xml) + }) + } + + /** + * EPub#handleRootFile() -> undefined + * + * Parses the rootfile XML and calls rootfile parser + **/ + handleRootFile() { + this.zip.readFile(this.rootFile, (err, data) => { + if (err) { + this.emit('error', new Error('Reading archive failed')) + return + } + const xml = data.toString('utf-8') + const xmlparser = new xml2js.Parser(xml2jsOptions) + + xmlparser.on('end', this.parseRootFile.bind(this)) + + xmlparser.on('error', (err) => { + this.emit('error', new Error(`Parsing container XML failed in handleRootFile: ${err.message}`)) + }) + + xmlparser.parseString(xml) + }) + } + + /** + * EPub#parseRootFile() -> undefined + * + * Parses elements "metadata," "manifest," "spine" and TOC. + * Emits "end" if no TOC + **/ + parseRootFile(rootfile) { + this.version = rootfile['@'].version || '2.0' + + let i, len, keys, keyparts, key + keys = Object.keys(rootfile) + for (i = 0, len = keys.length; i < len; i++) { + keyparts = keys[i].split(':') + key = (keyparts.pop() || '').toLowerCase().trim() + switch (key) { + case 'metadata': + this.parseMetadata(rootfile[keys[i]]) + break + case 'manifest': + this.parseManifest(rootfile[keys[i]]) + break + case 'spine': + this.parseSpine(rootfile[keys[i]]) + break + case 'guide': + this.parseGuide(rootfile[keys[i]]) + break + } + } + + if (this.spine.toc) + this.parseTOC() + else + this.emit('end') + } + + /** + * EPub#parseMetadata() -> undefined + * + * Parses "metadata" block (book metadata, title, author etc.) + **/ + parseMetadata(metadata) { + let i, j, len, keys, keyparts, key + + keys = Object.keys(metadata) + for (i = 0, len = keys.length; i < len; i++) { + keyparts = keys[i].split(':') + key = (keyparts.pop() || '').toLowerCase().trim() + switch (key) { + case 'publisher': + if (Array.isArray(metadata[keys[i]])) + this.metadata.publisher = String(metadata[keys[i]][0] && metadata[keys[i]][0]['#'] || metadata[keys[i]][0] || '').trim() + else + this.metadata.publisher = String(metadata[keys[i]]['#'] || metadata[keys[i]] || '').trim() + + break + case 'language': + if (Array.isArray(metadata[keys[i]])) + this.metadata.language = String(metadata[keys[i]][0] && metadata[keys[i]][0]['#'] || metadata[keys[i]][0] || '').toLowerCase().trim() + else + this.metadata.language = String(metadata[keys[i]]['#'] || metadata[keys[i]] || '').toLowerCase().trim() + + break + case 'title': + if (Array.isArray(metadata[keys[i]])) + this.metadata.title = String(metadata[keys[i]][0] && metadata[keys[i]][0]['#'] || metadata[keys[i]][0] || '').trim() + else + this.metadata.title = String(metadata[keys[i]]['#'] || metadata[keys[i]] || '').trim() + + break + case 'subject': + if (Array.isArray(metadata[keys[i]])) + this.metadata.subject = String(metadata[keys[i]][0] && metadata[keys[i]][0]['#'] || metadata[keys[i]][0] || '').trim() + else + this.metadata.subject = String(metadata[keys[i]]['#'] || metadata[keys[i]] || '').trim() + + break + case 'description': + if (Array.isArray(metadata[keys[i]])) + this.metadata.description = String(metadata[keys[i]][0] && metadata[keys[i]][0]['#'] || metadata[keys[i]][0] || '').trim() + else + this.metadata.description = String(metadata[keys[i]]['#'] || metadata[keys[i]] || '').trim() + + break + case 'creator': + if (Array.isArray(metadata[keys[i]])) { + this.metadata.creator = String(metadata[keys[i]][0] && metadata[keys[i]][0]['#'] || metadata[keys[i]][0] || '').trim() + this.metadata.creatorFileAs = String(metadata[keys[i]][0] && metadata[keys[i]][0]['@'] && metadata[keys[i]][0]['@']['opf:file-as'] || this.metadata.creator).trim() + } + else { + this.metadata.creator = String(metadata[keys[i]]['#'] || metadata[keys[i]] || '').trim() + this.metadata.creatorFileAs = String(metadata[keys[i]]['@'] && metadata[keys[i]]['@']['opf:file-as'] || this.metadata.creator).trim() + } + break + case 'date': + if (Array.isArray(metadata[keys[i]])) + this.metadata.date = String(metadata[keys[i]][0] && metadata[keys[i]][0]['#'] || metadata[keys[i]][0] || '').trim() + else + this.metadata.date = String(metadata[keys[i]]['#'] || metadata[keys[i]] || '').trim() + + break + case 'identifier': + if (metadata[keys[i]]['@'] && metadata[keys[i]]['@']['opf:scheme'] == 'ISBN') { + this.metadata.ISBN = String(metadata[keys[i]]['#'] || '').trim() + } + else if (metadata[keys[i]]['@'] && metadata[keys[i]]['@'].id && metadata[keys[i]]['@'].id.match(/uuid/i)) { + this.metadata.UUID = String(metadata[keys[i]]['#'] || '').replace('urn:uuid:', '').toUpperCase().trim() + } + else if (Array.isArray(metadata[keys[i]])) { + for (j = 0; j < metadata[keys[i]].length; j++) { + if (metadata[keys[i]][j]['@']) { + if (metadata[keys[i]][j]['@']['opf:scheme'] == 'ISBN') + this.metadata.ISBN = String(metadata[keys[i]][j]['#'] || '').trim() + else if (metadata[keys[i]][j]['@'].id && metadata[keys[i]][j]['@'].id.match(/uuid/i)) + this.metadata.UUID = String(metadata[keys[i]][j]['#'] || '').replace('urn:uuid:', '').toUpperCase().trim() + } + } + } + break + } + } + + const metas = metadata.meta || {} + Object.keys(metas).forEach(function (key) { + const meta = metas[key] + if (meta['@'] && meta['@'].name) { + const name = meta['@'].name + this.metadata[name] = meta['@'].content + } + if (meta['#'] && meta['@'].property) + this.metadata[meta['@'].property] = meta['#'] + + if (meta.name && meta.name == 'cover') + this.metadata[meta.name] = meta.content + }, this) + } + + /** + * EPub#parseManifest() -> undefined + * + * Parses "manifest" block (all items included, html files, images, styles) + **/ + parseManifest(manifest) { + let i; let len; const path = this.rootFile.split('/'); let element; let path_str + path.pop() + path_str = path.join('/') + + if (manifest.item) { + for (i = 0, len = manifest.item.length; i < len; i++) { + if (manifest.item[i]['@']) { + element = manifest.item[i]['@'] + + if (element.href && element.href.substr(0, path_str.length) != path_str) + element.href = path.concat([element.href]).join('/') + + this.manifest[manifest.item[i]['@'].id] = element + } + } + } + } + + /** + * EPub#parseGuide() -> undefined + * + * Parses "guide" block (locations of the fundamental structural components of the publication) + **/ + parseGuide(guide) { + let i; let len; const path = this.rootFile.split('/'); let element; let path_str + path.pop() + path_str = path.join('/') + + if (guide.reference) { + if (!Array.isArray(guide.reference)) + guide.reference = [guide.reference] + + for (i = 0, len = guide.reference.length; i < len; i++) { + if (guide.reference[i]['@']) { + element = guide.reference[i]['@'] + + if (element.href && element.href.substr(0, path_str.length) != path_str) + element.href = path.concat([element.href]).join('/') + + this.guide.push(element) + } + } + } + } + + /** + * EPub#parseSpine() -> undefined + * + * Parses "spine" block (all html elements that are shown to the reader) + **/ + parseSpine(spine) { + let i; let len; const path = this.rootFile.split('/'); let element + path.pop() + + if (spine['@'] && spine['@'].toc) + this.spine.toc = this.manifest[spine['@'].toc] || false + + if (spine.itemref) { + if (!Array.isArray(spine.itemref)) + spine.itemref = [spine.itemref] + + for (i = 0, len = spine.itemref.length; i < len; i++) { + if (spine.itemref[i]['@']) { + if (element = this.manifest[spine.itemref[i]['@'].idref]) + this.spine.contents.push(element) + } + } + } + this.flow = this.spine.contents + } + + /** + * EPub#parseTOC() -> undefined + * + * Parses ncx file for table of contents (title, html file) + **/ + parseTOC() { + let i; let len; const path = this.spine.toc.href.split('/'); const id_list = {}; let keys + path.pop() + + keys = Object.keys(this.manifest) + for (i = 0, len = keys.length; i < len; i++) + id_list[this.manifest[keys[i]].href] = keys[i] + + this.zip.readFile(this.spine.toc.href, (err, data) => { + if (err) { + this.emit('error', new Error('Reading archive failed')) + return + } + const xml = data.toString('utf-8') + const xmlparser = new xml2js.Parser(xml2jsOptions) + + xmlparser.on('end', (result) => { + if (result.navMap && result.navMap.navPoint) + this.toc = this.walkNavMap(result.navMap.navPoint, path, id_list) + + this.emit('end') + }) + + xmlparser.on('error', (err) => { + this.emit('error', new Error(`Parsing container XML failed in TOC: ${err.message}`)) + }) + + xmlparser.parseString(xml) + }) + } + + /** + * EPub#walkNavMap(branch, path, id_list,[, level]) -> Array + * - branch (Array | Object): NCX NavPoint object + * - path (Array): Base path + * - id_list (Object): map of file paths and id values + * - level (Number): deepness + * + * Walks the NavMap object through all levels and finds elements + * for TOC + **/ + walkNavMap(branch, path, id_list, level) { + level = level || 0 + + // don't go too far + if (level > 7) + return [] + + let output = [] + + if (!Array.isArray(branch)) + branch = [branch] + + for (let i = 0; i < branch.length; i++) { + if (branch[i].navLabel) { + let title = '' + if (branch[i].navLabel && typeof branch[i].navLabel.text == 'string') { + title = branch[i].navLabel && branch[i].navLabel.text || branch[i].navLabel === branch[i].navLabel && branch[i].navLabel.text.length > 0 + ? (branch[i].navLabel && branch[i].navLabel.text || branch[i].navLabel || '').trim() + : '' + } + let order = Number(branch[i]['@'] && branch[i]['@'].playOrder || 0) + if (isNaN(order)) + order = 0 + + let href = '' + if (branch[i].content && branch[i].content['@'] && typeof branch[i].content['@'].src == 'string') + href = branch[i].content['@'].src.trim() + + let element = { + level, + order, + title, + } + + if (href) { + href = path.concat([href]).join('/') + element.href = href + + if (id_list[element.href]) { + // link existing object + element = this.manifest[id_list[element.href]] + element.title = title + element.order = order + element.level = level + } + else { + // use new one + element.href = href + element.id = (branch[i]['@'] && branch[i]['@'].id || '').trim() + } + + output.push(element) + } + } + if (branch[i].navPoint) + output = output.concat(this.walkNavMap(branch[i].navPoint, path, id_list, level + 1)) + } + return output + } + + /** + * EPub#getChapter(id, callback) -> undefined + * - id (String): Manifest id value for a chapter + * - callback (Function): callback function + * + * Finds a chapter text for an id. Replaces image and link URL's, removes + * etc. elements. Return only chapters with mime type application/xhtml+xml + **/ + getChapter(id, callback) { + this.getChapterRaw(id, (err, str) => { + if (err) { + callback(err) + return + } + + let i; let len; const path = this.rootFile.split('/'); const keys = Object.keys(this.manifest) + path.pop() + + // remove linebreaks (no multi line matches in JS regex!) + str = str.replace(/\r?\n/g, '\u0000') + + // keep only contents + str.replace(/]*?>(.*)<\/body[^>]*?>/i, (o, d) => { + str = d.trim() + }) + + // remove diff --git a/packages/web/src/components/Setting/components/SettingRow/index.vue b/packages/web/src/components/Setting/components/SettingRow/index.vue new file mode 100644 index 00000000..300d43d2 --- /dev/null +++ b/packages/web/src/components/Setting/components/SettingRow/index.vue @@ -0,0 +1,12 @@ + + + diff --git a/packages/web/src/components/Setting/index.vue b/packages/web/src/components/Setting/index.vue new file mode 100644 index 00000000..b51f037f --- /dev/null +++ b/packages/web/src/components/Setting/index.vue @@ -0,0 +1,27 @@ + + + diff --git a/packages/web/src/main.ts b/packages/web/src/main.ts index e1116414..4138f678 100644 --- a/packages/web/src/main.ts +++ b/packages/web/src/main.ts @@ -15,12 +15,14 @@ import './plugins/vsc-ui'; import ArcoVue from '@arco-design/web-vue'; import Icon from '@arco-design/web-vue/es/icon'; import '@arco-design/web-vue/dist/arco.css'; +import { Modal } from '@arco-design/web-vue'; import { createPinia } from 'pinia'; import '@/utils/monaco'; const app = createApp(App); +Modal._context = app._context; app.use(router); app.use(ArcoVue); diff --git a/packages/web/src/pages/pc/books/index.vue b/packages/web/src/pages/pc/books/index.vue index da420c0e..16eff335 100644 --- a/packages/web/src/pages/pc/books/index.vue +++ b/packages/web/src/pages/pc/books/index.vue @@ -14,7 +14,7 @@
- diff --git a/packages/web/src/pages/pc/category/index.vue b/packages/web/src/pages/pc/category/index.vue index f05ddc2d..0cccee2d 100644 --- a/packages/web/src/pages/pc/category/index.vue +++ b/packages/web/src/pages/pc/category/index.vue @@ -52,7 +52,7 @@
- diff --git a/packages/web/src/pages/pc/content/index.vue b/packages/web/src/pages/pc/content/index.vue index 52920f43..5f58c673 100644 --- a/packages/web/src/pages/pc/content/index.vue +++ b/packages/web/src/pages/pc/content/index.vue @@ -1,15 +1,28 @@ -