diff --git a/.gitignore b/.gitignore index 24d65ea..ee83ba7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,10 @@ node_modules dist *.db +*.tsbuildinfo .env uploads .DS_Store config.json release -**/cache/* \ No newline at end of file +**/cache/* diff --git a/backend/src/items/item.entity.ts b/backend/src/items/item.entity.ts index 6abb699..ffaaf1e 100644 --- a/backend/src/items/item.entity.ts +++ b/backend/src/items/item.entity.ts @@ -19,8 +19,8 @@ export enum ItemStatus { export const ItemTypeLabels = { [ItemType.EXTENSION]: '扩展', [ItemType.THEME]: '主题', - [ItemType.APP_EXTENSION]: 'APP扩展', - [ItemType.APP_THEME]: 'APP主题', + [ItemType.APP_EXTENSION]: '客户端扩展', + [ItemType.APP_THEME]: '客户端主题', }; @Entity() diff --git a/backend/src/items/items.controller.ts b/backend/src/items/items.controller.ts index 8a65d9b..6374be9 100644 --- a/backend/src/items/items.controller.ts +++ b/backend/src/items/items.controller.ts @@ -61,6 +61,21 @@ export class ItemsController { return this.itemsService.getUserPurchases(req.user.userId, type); } + @Get('url-rules') + @UseGuards(JwtAuthGuard) + async getUserUrlRules(@Request() req) { + return this.itemsService.getUserUrlRules(req.user.userId); + } + + @Post('url-rules') + @UseGuards(JwtAuthGuard) + async setUserUrlRules( + @Body() body: { allowUrls?: string[]; blockUrls?: string[] }, + @Request() req, + ) { + return this.itemsService.setUserUrlRules(req.user.userId, body.allowUrls, body.blockUrls); + } + @Get('my-published') @UseGuards(JwtAuthGuard) async getMyPublished(@Request() req, @Query('type') type?: ItemType) { @@ -155,17 +170,19 @@ export class ItemsController { return; } - const origin = req.query.origin; - - const items = await this.itemsService.getUserPurchases(user.id); + const [items, globalUrlRules] = await Promise.all([ + this.itemsService.getUserPurchases(user.id), + this.itemsService.getUserUrlRules(user.id), + ]); const extensionIds = items.filter(p => p.type === 'extension' && p.isEnabled).map(i => i.id); const themeIds = items.filter(p => p.type === 'theme' && p.isEnabled).map(i => i.id); const extensionData = items.filter(p => p.isEnabled) - .filter(p => !origin || p.matchUrls.some((pattern: string) => new RegExp(pattern.replace(/\*/g, '.*')).test(origin))) .map(i => ({ id: i.id, name: i.name, matchUrls: i.matchUrls, + allowUrls: i.allowUrls, + blockUrls: i.blockUrls, identifier: i.identifier, dependencies: i.dependencies?.map(d => d.id) || [] })); @@ -174,7 +191,8 @@ export class ItemsController { .replace(/`{{#Ids}}`/, extensionIds.join(', ')) .replace(/`{{#Themes}}`/, themeIds.join(', ')) .replace(/['"`]{{#UserId}}['"`]/, `'${user.id}'`) - .replace(/\[`{{#ExtensionData}}`\]/, JSON.stringify(extensionData)) + .replace(/\[`{{#ExtensionData}}`\]/, () => JSON.stringify(extensionData)) + .replace(/\[`{{#GlobalUrlRules}}`\]/, () => JSON.stringify(globalUrlRules)) ); } @@ -338,6 +356,22 @@ export class ItemsController { return this.itemsService.findOne(id, req.user.userId); } + @Get(':id/url-rules') + @UseGuards(JwtAuthGuard) + async getItemUrlRules(@Param('id', ParseIntPipe) id: number, @Request() req) { + return this.itemsService.getItemUrlRules(id, req.user.userId); + } + + @Post(':id/url-rules') + @UseGuards(JwtAuthGuard) + async setItemUrlRules( + @Param('id', ParseIntPipe) id: number, + @Body() body: { allowUrls?: string[]; blockUrls?: string[] }, + @Request() req, + ) { + return this.itemsService.setItemUrlRules(id, req.user.userId, body.allowUrls, body.blockUrls); + } + @Get(':id/versions') @UseGuards(JwtAuthGuard) async getVersions(@Param('id', ParseIntPipe) id: number, @Request() req) { diff --git a/backend/src/items/items.module.ts b/backend/src/items/items.module.ts index e73cf24..6467fe5 100644 --- a/backend/src/items/items.module.ts +++ b/backend/src/items/items.module.ts @@ -4,13 +4,14 @@ import { Item } from './item.entity'; import { UserItemState } from './user-item-state.entity'; import { Comment } from './comment.entity'; import { GlobalStorage } from './global-storage.entity'; +import { UserUrlRule } from './user-url-rule.entity'; import { ItemsService } from './items.service'; import { ItemsController } from './items.controller'; import { UsersModule } from '../users/users.module'; @Module({ imports: [ - TypeOrmModule.forFeature([Item, UserItemState, Comment, GlobalStorage]), + TypeOrmModule.forFeature([Item, UserItemState, UserUrlRule, Comment, GlobalStorage]), forwardRef(() => UsersModule), ], providers: [ItemsService], diff --git a/backend/src/items/items.service.ts b/backend/src/items/items.service.ts index 2fe7767..a41e914 100644 --- a/backend/src/items/items.service.ts +++ b/backend/src/items/items.service.ts @@ -9,6 +9,7 @@ import { Item, ItemStatus, ItemType, ItemTypeLabels } from './item.entity'; import { Comment } from './comment.entity'; import { UserItemState } from './user-item-state.entity'; import { GlobalStorage } from './global-storage.entity'; +import { UserUrlRule } from './user-url-rule.entity'; import { UsersService } from '../users/users.service'; import Fishpi, { FingerTo } from 'fishpi'; import { ConfigService } from 'src/config/config.service'; @@ -20,6 +21,8 @@ export class ItemsService { private itemsRepository: Repository, @InjectRepository(UserItemState) private itemStateRepository: Repository, + @InjectRepository(UserUrlRule) + private userUrlRuleRepository: Repository, @InjectRepository(Comment) private commentRepository: Repository, @InjectRepository(GlobalStorage) @@ -34,6 +37,86 @@ export class ItemsService { private readonly gzipAsync = promisify(gzip); private readonly gunzipAsync = promisify(gunzip); + private normalizeUrlRules(urls: unknown): string[] { + if (!Array.isArray(urls)) return []; + return [...new Set(urls + .filter((url): url is string => typeof url === 'string') + .map(url => url.trim()) + .filter(Boolean))]; + } + + private formatUrlRules(rule?: { allowUrls?: string[]; blockUrls?: string[] } | null) { + return { + allowUrls: this.normalizeUrlRules(rule?.allowUrls), + blockUrls: this.normalizeUrlRules(rule?.blockUrls), + }; + } + + private async findOwnedItem(itemId: number, userId: string): Promise { + const item = await this.itemsRepository.createQueryBuilder('item') + .leftJoin('item.purchasedBy', 'purchasedBy') + .leftJoinAndSelect('item.author', 'author') + .where('item.id = :itemId', { itemId }) + .andWhere('(purchasedBy.id = :userId OR author.id = :userId)', { userId }) + .getOne(); + if (!item) throw new UnauthorizedException('您尚未拥有此项目'); + if (item.type !== ItemType.EXTENSION && item.type !== ItemType.THEME) { + throw new BadRequestException('仅网页扩展和主题支持网址设置'); + } + return item; + } + + async getUserUrlRules(userId: string) { + const rule = await this.userUrlRuleRepository.findOne({ + where: { user: { id: userId } }, + }); + return this.formatUrlRules(rule); + } + + async setUserUrlRules(userId: string, allowUrls: unknown, blockUrls: unknown) { + const user = await this.usersService.findById(userId); + if (!user) throw new NotFoundException('找不到此用户'); + const rules = this.formatUrlRules({ allowUrls: allowUrls as string[], blockUrls: blockUrls as string[] }); + let rule = await this.userUrlRuleRepository.findOne({ + where: { user: { id: userId } }, + }); + if (!rule) rule = this.userUrlRuleRepository.create({ user }); + Object.assign(rule, rules); + await this.userUrlRuleRepository.save(rule); + return rules; + } + + async getItemUrlRules(itemId: number, userId: string) { + await this.findOwnedItem(itemId, userId); + const state = await this.itemStateRepository.findOne({ + where: { user: { id: userId }, item: { id: itemId } }, + }); + return this.formatUrlRules(state); + } + + async setItemUrlRules(itemId: number, userId: string, allowUrls: unknown, blockUrls: unknown) { + const [user, item] = await Promise.all([ + this.usersService.findById(userId), + this.findOwnedItem(itemId, userId), + ]); + if (!user) throw new NotFoundException('找不到此用户'); + const rules = this.formatUrlRules({ allowUrls: allowUrls as string[], blockUrls: blockUrls as string[] }); + let state = await this.itemStateRepository.findOne({ + where: { user: { id: userId }, item: { id: itemId } }, + }); + if (!state) { + state = this.itemStateRepository.create({ + user, + item, + isEnabled: true, + isAutoUpdate: true, + }); + } + Object.assign(state, rules); + await this.itemStateRepository.save(state); + return rules; + } + private async writeApprovedItemCache(item: Item): Promise { try { await fsp.mkdir(this.CACHE_DIR, { recursive: true }); @@ -83,7 +166,7 @@ export class ItemsService { } const regex = /\/\/\s*==FishPiPlugin==[\s\S]*?\/\/\s*==\/FishPiPlugin==/; if (!regex.test(data.code)) { - throw new BadRequestException('APP扩展内容前端必须包含 // ==FishPiPlugin== 与 // ==/FishPiPlugin== 元数据'); + throw new BadRequestException('客户端扩展必须包含 FishPiPlugin 头部'); } } else if (data.type === ItemType.APP_THEME) { if (!data.code) { @@ -92,10 +175,10 @@ export class ItemsService { try { const parsed = JSON.parse(data.code); if (typeof parsed !== 'object' || parsed === null) { - throw new BadRequestException('APP主题内容必须是一个有效的JSON对象'); + throw new BadRequestException('客户端主题内容必须是 JSON 对象'); } } catch (e: any) { - throw new BadRequestException('APP主题内容必须是一个合法的JSON格式: ' + e.message); + throw new BadRequestException('客户端主题 JSON 格式错误: ' + e.message); } } } @@ -322,7 +405,7 @@ export class ItemsService { throw new NotFoundException('没找到'); } - const isPurchased = userId ? await this.itemsRepository.createQueryBuilder('item') + const isPurchased = userId && item.author.id !== userId ? await this.itemsRepository.createQueryBuilder('item') .innerJoin('item.purchasedBy', 'purchasedBy') .where('item.id = :id AND purchasedBy.id = :userId', { id, userId }) .getCount() > 0 : false; @@ -330,6 +413,7 @@ export class ItemsService { const purchaseCountResult = await this.itemsRepository.createQueryBuilder('item') .leftJoin('item.purchasedBy', 'purchasedBy') .where('item.id = :id', { id }) + .andWhere('(purchasedBy.id IS NULL OR purchasedBy.id != item.authorId)') .select('COUNT(purchasedBy.id)', 'count') .getRawOne(); @@ -459,6 +543,7 @@ export class ItemsService { } for (const { user, wasEnabled, state } of ownersToUpdate) { + const inheritedRules = this.formatUrlRules(state); // Remove old version state if exists if (state) { await this.itemStateRepository.remove(state); @@ -469,23 +554,38 @@ export class ItemsService { where: { user: { id: user.id }, item: { id: item.id } } }); - if (!newState && !wasEnabled) { + if (!newState && ( + !wasEnabled || + inheritedRules.allowUrls.length || + inheritedRules.blockUrls.length || + user.id === item.author.id + )) { newState = this.itemStateRepository.create({ user: user, item: item, isEnabled: wasEnabled, - isAutoUpdate: true + isAutoUpdate: true, + ...inheritedRules, }); await this.itemStateRepository.save(newState); + } else if (newState) { + Object.assign(newState, inheritedRules); + await this.itemStateRepository.save(newState); } // Transfer ownership try { - await this.itemsRepository.createQueryBuilder() - .relation(Item, 'purchasedBy') - .of(item.id) - .add(user.id); - + if (user.id !== item.author.id) { + await this.itemsRepository.createQueryBuilder() + .relation(Item, 'purchasedBy') + .of(item.id) + .add(user.id); + } else { + await this.itemsRepository.createQueryBuilder() + .relation(Item, 'purchasedBy') + .of(item.id) + .remove(user.id); + } await this.itemsRepository.createQueryBuilder() .relation(Item, 'purchasedBy') .of(item.upgradeFrom.id) @@ -571,6 +671,11 @@ export class ItemsService { throw new BadRequestException('未通过审核'); } + if (item.author.id === userId) { + await this.toggleItemState(itemId, userId, true); + return item; + } + // Check if already purchased const alreadyPurchased = item.purchasedBy?.some(u => u.id === userId); if (alreadyPurchased) { @@ -588,9 +693,13 @@ export class ItemsService { }); let ownsOtherVersion = false; + let previousState: UserItemState | null = null; for (const v of allVersions) { if (v.id !== item.id && v.purchasedBy?.some(u => u.id === userId)) { ownsOtherVersion = true; + previousState = await this.itemStateRepository.findOne({ + where: { user: { id: userId }, item: { id: v.id } }, + }); // Remove ownership from other versions (User can only own one version at a time) v.purchasedBy = v.purchasedBy.filter(u => u.id !== userId); await this.itemsRepository.save(v); @@ -621,6 +730,25 @@ export class ItemsService { item.purchasedBy.push(user); const savedItem = await this.itemsRepository.save(item); + if (ownsOtherVersion) { + const inheritedRules = this.formatUrlRules(previousState); + let nextState = await this.itemStateRepository.findOne({ + where: { user: { id: userId }, item: { id: item.id } }, + }); + if (!nextState && previousState) { + nextState = this.itemStateRepository.create({ + user, + item, + isEnabled: previousState.isEnabled, + isAutoUpdate: previousState.isAutoUpdate, + }); + } + if (nextState) { + Object.assign(nextState, inheritedRules); + await this.itemStateRepository.save(nextState); + } + } + // Handle dependencies: grant free ones automatically if (item.dependencies && item.dependencies.length > 0) { for (const dep of item.dependencies) { @@ -670,7 +798,7 @@ export class ItemsService { .leftJoinAndSelect('item.dependencies', 'dependencies') .leftJoin('item.purchasedBy', 'purchasedBy') .leftJoin(UserItemState, 'state', 'state.itemId = item.id AND state.userId = :userId', { userId }) - .where('purchasedBy.id = :userId OR (author.id = :userId AND state.id IS NOT NULL)', { userId }) + .where('(purchasedBy.id = :userId AND author.id != :userId) OR (author.id = :userId AND state.id IS NOT NULL)', { userId }) .andWhere(type ? 'item.type = :type' : '1=1', { type }) .getMany(); @@ -684,7 +812,8 @@ export class ItemsService { return { ...item, isEnabled: state ? state.isEnabled : true, - isAutoUpdate: state ? state.isAutoUpdate : true + isAutoUpdate: state ? state.isAutoUpdate : true, + ...this.formatUrlRules(state), }; }); @@ -725,6 +854,8 @@ export class ItemsService { throw new UnauthorizedException('您尚未拥有此项目'); } + const isAuthor = item.author.id === userId; + // 如果当前要开启,则关闭该项目(同作者、同名、同类型)的其他版本 if (isEnabled) { const allVersionsOfProject = await this.itemsRepository.find({ @@ -734,8 +865,20 @@ export class ItemsService { type: item.type, } }); + + if (isAuthor) { + for (const version of allVersionsOfProject) { + await this.itemsRepository.createQueryBuilder() + .relation(Item, 'purchasedBy') + .of(version.id) + .remove(userId); + } + } let storageToMigrate = null; + let allowUrlsToMigrate: string[] = []; + let blockUrlsToMigrate: string[] = []; + let urlRulesToMigrate = false; for (const v of allVersionsOfProject) { if (v.id !== itemId) { @@ -762,6 +905,9 @@ export class ItemsService { if (otherState.storage && Object.keys(otherState.storage).length > 0) { storageToMigrate = otherState.storage; } + allowUrlsToMigrate = this.normalizeUrlRules(otherState.allowUrls); + blockUrlsToMigrate = this.normalizeUrlRules(otherState.blockUrls); + urlRulesToMigrate = true; await this.itemStateRepository.remove(otherState); } } @@ -777,13 +923,19 @@ export class ItemsService { item, isEnabled, isAutoUpdate: true, - storage: storageToMigrate || {} + storage: storageToMigrate || {}, + allowUrls: allowUrlsToMigrate, + blockUrls: blockUrlsToMigrate, }); } else { state.isEnabled = isEnabled; if (storageToMigrate) { state.storage = { ...storageToMigrate, ...(state.storage || {}) }; } + if (urlRulesToMigrate) { + state.allowUrls = allowUrlsToMigrate; + state.blockUrls = blockUrlsToMigrate; + } } const allVersionsSorted = await this.itemsRepository.find({ @@ -982,7 +1134,7 @@ export class ItemsService { } const regex = /\/\/\s*==FishPiPlugin==[\s\S]*?\/\/\s*==\/FishPiPlugin==/; if (!regex.test(item.code)) { - throw new BadRequestException('APP扩展内容前端必须包含 // ==FishPiPlugin== 与 // ==/FishPiPlugin== 元数据'); + throw new BadRequestException('客户端扩展必须包含 FishPiPlugin 头部'); } } else if (item.type === ItemType.APP_THEME) { if (!item.code) { @@ -991,10 +1143,10 @@ export class ItemsService { try { const parsed = JSON.parse(item.code); if (typeof parsed !== 'object' || parsed === null) { - throw new BadRequestException('APP主题内容必须是一个有效的JSON对象'); + throw new BadRequestException('客户端主题内容必须是 JSON 对象'); } } catch (e: any) { - throw new BadRequestException('APP主题内容必须是一个合法的JSON格式: ' + e.message); + throw new BadRequestException('客户端主题 JSON 格式错误: ' + e.message); } } } diff --git a/backend/src/items/user-item-state.entity.ts b/backend/src/items/user-item-state.entity.ts index 08b21b2..b2e5a4b 100644 --- a/backend/src/items/user-item-state.entity.ts +++ b/backend/src/items/user-item-state.entity.ts @@ -22,4 +22,10 @@ export class UserItemState { @Column({ type: 'simple-json', nullable: true, comment: '配置数据' }) storage: Record; + + @Column({ type: 'simple-json', nullable: true, comment: '白名单地址' }) + allowUrls: string[]; + + @Column({ type: 'simple-json', nullable: true, comment: '黑名单地址' }) + blockUrls: string[]; } diff --git a/backend/src/items/user-url-rule.entity.ts b/backend/src/items/user-url-rule.entity.ts new file mode 100644 index 0000000..c3fc73b --- /dev/null +++ b/backend/src/items/user-url-rule.entity.ts @@ -0,0 +1,18 @@ +import { Column, Entity, JoinColumn, OneToOne, PrimaryGeneratedColumn } from 'typeorm'; +import { User } from '../users/user.entity'; + +@Entity() +export class UserUrlRule { + @PrimaryGeneratedColumn() + id: number; + + @OneToOne(() => User, { onDelete: 'CASCADE' }) + @JoinColumn() + user: User; + + @Column({ type: 'simple-json', nullable: true, comment: '白名单地址' }) + allowUrls: string[]; + + @Column({ type: 'simple-json', nullable: true, comment: '黑名单地址' }) + blockUrls: string[]; +} diff --git a/frontend/src/api/items.ts b/frontend/src/api/items.ts index 5ec01ea..d2c14a7 100644 --- a/frontend/src/api/items.ts +++ b/frontend/src/api/items.ts @@ -4,8 +4,8 @@ import request from '../utils/request' export const ItemTypeLabels: any = { 'extension': '扩展', 'theme': '主题', - 'app-extension': 'APP扩展', - 'app-theme': 'APP主题', + 'app-extension': '客户端扩展', + 'app-theme': '客户端主题', }; export interface Item { @@ -30,11 +30,18 @@ export interface Item { isEnabled?: boolean isAutoUpdate?: boolean matchUrls?: string[] + allowUrls?: string[] + blockUrls?: string[] upgradeFrom?: Item | number upgradeFromId?: number dependencies?: Item[] } +export interface UrlRules { + allowUrls: string[] + blockUrls: string[] +} + export interface Comment { id: number content: string @@ -113,6 +120,22 @@ export function getPurchasedItems() { return request.get('/items/my-purchases') } +export function getUserUrlRules() { + return request.get('/items/url-rules') +} + +export function setUserUrlRules(rules: UrlRules) { + return request.post('/items/url-rules', rules) +} + +export function getItemUrlRules(id: number) { + return request.get(`/items/${id}/url-rules`) +} + +export function setItemUrlRules(id: number, rules: UrlRules) { + return request.post(`/items/${id}/url-rules`, rules) +} + /** * Get item by ID */ diff --git a/frontend/src/components/UrlRulesModal.vue b/frontend/src/components/UrlRulesModal.vue new file mode 100644 index 0000000..f41ece3 --- /dev/null +++ b/frontend/src/components/UrlRulesModal.vue @@ -0,0 +1,120 @@ + + + diff --git a/frontend/src/layout/components/header.vue b/frontend/src/layout/components/header.vue index 5d1ad50..b8c196d 100644 --- a/frontend/src/layout/components/header.vue +++ b/frontend/src/layout/components/header.vue @@ -78,7 +78,7 @@ const logout = () => {
  • - APP扩展与主题 + 客户端扩展与主题
  • diff --git a/frontend/src/views/AppUploadView.vue b/frontend/src/views/AppUploadView.vue index 37a36c4..188ace4 100644 --- a/frontend/src/views/AppUploadView.vue +++ b/frontend/src/views/AppUploadView.vue @@ -394,18 +394,18 @@ const handleSubmit = async (isDraft: boolean = false) => { if (type.value === 'app-extension') { const regex = /\/\/\s*==FishPiPlugin==[\s\S]*?\/\/\s*==\/FishPiPlugin==/ if (!regex.test(code.value)) { - error.value = 'APP扩展前端内容必须包含 // ==FishPiPlugin== 到 // ==/FishPiPlugin== 头部声明元数据' + error.value = '客户端扩展必须包含 FishPiPlugin 头部' return } } else if (type.value === 'app-theme') { try { const parsed = JSON.parse(code.value) if (typeof parsed !== 'object' || parsed === null) { - error.value = 'APP主题内容必须是一个有效的JSON对象' + error.value = '客户端主题内容必须是 JSON 对象' return } } catch (err: any) { - error.value = 'APP主题内容必须是一个合法的JSON格式: ' + err.message + error.value = '客户端主题 JSON 格式错误: ' + err.message return } } @@ -482,11 +482,11 @@ const handleSubmit = async (isDraft: boolean = false) => {
    1
    -

    APP 扩展必须以 FishPiPlugin 头部格式开始

    +

    客户端扩展需包含 FishPiPlugin 头部

    2
    -

    APP 主题必须是合法的 JSON 格式配置

    +

    客户端主题需使用 JSON 格式

    3
    @@ -657,7 +657,7 @@ const handleSubmit = async (isDraft: boolean = false) => {
    - +
    @@ -681,8 +681,8 @@ const handleSubmit = async (isDraft: boolean = false) => {
    @@ -841,7 +841,7 @@ const handleSubmit = async (isDraft: boolean = false) => {
    @@ -935,4 +935,4 @@ const handleSubmit = async (isDraft: boolean = false) => {
    - \ No newline at end of file + diff --git a/frontend/src/views/HomeView.vue b/frontend/src/views/HomeView.vue index 60621d8..c207119 100644 --- a/frontend/src/views/HomeView.vue +++ b/frontend/src/views/HomeView.vue @@ -2,7 +2,7 @@ import { ref, onMounted, computed, watch } from 'vue' import { useRouter } from 'vue-router' import { useAuthStore } from '../stores/auth' -import { getItems, purchaseItem, getPurchasedItems, ItemTypeLabels } from '@/api/items' +import { getItems, purchaseItem, getPurchasedItems, toggleItemState, ItemTypeLabels } from '@/api/items' import Message from '@/components/msg' import MessageBox from '@/components/msgbox' import { useDependencyCheck } from '@/utils/hooks' @@ -103,6 +103,32 @@ const handlePurchase = async (item: any) => { } } +const handleAuthorInstall = async (item: any) => { + const ownedVersion = getOwnedVersionOfSameProject(item) + if (ownedVersion) { + const isUpdate = (item.version || 1) > (ownedVersion.version || 1) + const confirmed = await MessageBox.confirm( + `从 v${ownedVersion.version || 1} ${isUpdate ? '升级' : '切换'}到 v${item.version || 1}?`, + isUpdate ? '版本升级' : '切换版本' + ) + if (!confirmed) return + } + + if (!await checkDependencies(item, { + title: '运行依赖', + messagePrefix: '此作品需要以下依赖', + messageSuffix: '继续安装?' + })) return + + try { + await toggleItemState(item.id, true) + Message.success(ownedVersion ? '切换成功' : '安装成功') + await loadItems() + } catch (error: any) { + console.error('Install failed:', error) + } +} + const filteredItems = computed(() => { return items.value }) @@ -112,6 +138,10 @@ const isPurchased = (item: any) => { return myPurchases.value.some((p: any) => p.id === item.id) } +const isAuthor = (item: any) => { + return authStore.isAuthenticated && item.author?.id === authStore.user?.id +} + const getOwnedVersionOfSameProject = (item: any) => { if (!authStore.isAuthenticated) return null return myPurchases.value.find(p => @@ -189,7 +219,7 @@ onMounted(() => { @click="activeTab = 'app-theme'" class="btn btn-sm join-item px-4" :class="activeTab === 'app-theme' ? 'btn-primary shadow-sm' : 'btn-soft opacity-60'" - >APP主题 + >客户端主题
    @@ -248,7 +278,15 @@ onMounted(() => {
    diff --git a/frontend/src/views/MyPurchasesView.vue b/frontend/src/views/MyPurchasesView.vue index d3b081a..c307348 100644 --- a/frontend/src/views/MyPurchasesView.vue +++ b/frontend/src/views/MyPurchasesView.vue @@ -5,12 +5,22 @@ import { getPurchasedItems, toggleItemState, getItems, purchaseItem, setAutoUpda import message from '@/components/msg' import MessageBox from '@/components/msgbox' import { useDependencyCheck } from '@/utils/hooks' +import UrlRulesModal from '@/components/UrlRulesModal.vue' +import { useAuthStore } from '@/stores/auth' const { checkDependencies } = useDependencyCheck() const router = useRouter() +const authStore = useAuthStore() const items = ref([]) const latestItems = ref([]) const loading = ref(true) +const showUrlRules = ref(false) +const urlRulesItem = ref(null) + +const openUrlRules = (item?: any) => { + urlRulesItem.value = item || null + showUrlRules.value = true +} const loadPurchasedItems = async () => { loading.value = true @@ -50,8 +60,12 @@ const handleUpgrade = async (item: any, latest: any) => { })) return try { - await purchaseItem(latest.id) - message.success('升级成功!') + if (item.author?.id === authStore.user?.id) { + await toggleItemState(latest.id, true) + } else { + await purchaseItem(latest.id) + } + message.success('升级成功') await loadPurchasedItems() } catch (error: any) { console.error('Upgrade failed:', error) @@ -93,8 +107,11 @@ const toggleEnabled = async (item: any) => { } const handleRemove = async (item: any) => { - let warningMsg = `确定要从账号中移除 ${item.name} 吗?

    移除后您将无法继续使用此${item.type === 'extension' ? '扩展' : '主题'}以及获得相关更新。` - if (item.price && item.price > 0) { + const isAuthor = item.author?.id === authStore.user?.id + let warningMsg = isAuthor + ? `卸载 ${item.name}?` + : `确定要从账号中移除 ${item.name} 吗?

    移除后您将无法继续使用此${item.type === 'extension' ? '扩展' : '主题'}以及获得相关更新。` + if (!isAuthor && item.price && item.price > 0) { warningMsg += `

    警告:此为付费项目,移除后购买费用( ${item.price} 积分 )将不会退回!重新获取将需再次付费!` } @@ -109,7 +126,7 @@ const handleRemove = async (item: any) => { try { await removePurchaseItem(item.id) - message.success('移除成功') + message.success(isAuthor ? '已卸载' : '移除成功') await loadPurchasedItems() } catch (error: any) { console.error('Remove failed:', error) @@ -126,7 +143,7 @@ onMounted(() => {
    -
    +
    @@ -135,9 +152,13 @@ onMounted(() => { 已购项目 {{ items.length }} -

    查看和管理您已获取的扩展与主题

    +

    管理已获取的扩展与主题

    +
    @@ -172,6 +193,11 @@ onMounted(() => {
    +
    + +
    { 前往集市
    + +
    diff --git a/frontend/src/views/MyWorksView.vue b/frontend/src/views/MyWorksView.vue index dae3265..feb6795 100644 --- a/frontend/src/views/MyWorksView.vue +++ b/frontend/src/views/MyWorksView.vue @@ -251,8 +251,8 @@ onMounted(() => { - - + +
    @@ -398,4 +398,4 @@ onMounted(() => { - \ No newline at end of file + diff --git a/frontend/src/views/UserView.vue b/frontend/src/views/UserView.vue index 49006e1..1a934f5 100644 --- a/frontend/src/views/UserView.vue +++ b/frontend/src/views/UserView.vue @@ -2,7 +2,7 @@ import { ref, onMounted, computed, watch } from 'vue' import { useRoute, useRouter } from 'vue-router' import { useAuthStore } from '../stores/auth' -import { getItemsByAuthor, purchaseItem, getPurchasedItems } from '@/api/items' +import { getItemsByAuthor, purchaseItem, getPurchasedItems, toggleItemState } from '@/api/items' import { getUser, getUserComments, type UserProfile } from '@/api/user' import Message from '@/components/msg' import MessageBox from '@/components/msgbox' @@ -92,6 +92,33 @@ const handlePurchase = async (item: any) => { } } +const handleAuthorInstall = async (item: any) => { + const ownedVersion = getOwnedVersionOfSameProject(item) + if (ownedVersion) { + const isUpdate = (item.version || 1) > (ownedVersion.version || 1) + const confirmed = await MessageBox.confirm( + `从 v${ownedVersion.version || 1} ${isUpdate ? '升级' : '切换'}到 v${item.version || 1}?`, + isUpdate ? '版本升级' : '切换版本' + ) + if (!confirmed) return + } + + if (!await checkDependencies(item, { + title: '运行依赖', + messagePrefix: '此作品需要以下依赖', + messageSuffix: '继续安装?' + })) return + + try { + await toggleItemState(item.id, true) + Message.success(ownedVersion ? '切换成功' : '安装成功') + const purchasesRes = await getPurchasedItems() + myPurchases.value = purchasesRes.data + } catch (error: any) { + console.error('Install failed:', error) + } +} + const filteredItems = computed(() => { let filtered = items.value @@ -108,6 +135,10 @@ const isPurchased = (item: any) => { return myPurchases.value.some((p: any) => p.id === item.id) } +const isAuthor = (item: any) => { + return authStore.isAuthenticated && item.author?.id === authStore.user?.id +} + const getOwnedVersionOfSameProject = (item: any) => { if (!authStore.isAuthenticated) return null return myPurchases.value.find(p => @@ -259,7 +290,15 @@ watch(() => route.params.username, () => {