mirror of
https://github.com/Smile-QWQ/SubTracker.git
synced 2026-06-20 22:17:02 +08:00
feat: initialize SubTracker repository
This commit is contained in:
11
.dockerignore
Normal file
11
.dockerignore
Normal file
@@ -0,0 +1,11 @@
|
||||
node_modules
|
||||
apps/*/node_modules
|
||||
apps/*/dist
|
||||
packages/*/dist
|
||||
.git
|
||||
.idea
|
||||
.vscode
|
||||
data
|
||||
apps/api/dev.db
|
||||
apps/api/storage/logos
|
||||
npm-debug.log
|
||||
33
.github/workflows/ci.yml
vendored
Normal file
33
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,33 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
build-and-check:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Prisma generate
|
||||
run: npm run prisma:generate
|
||||
|
||||
- name: Lint
|
||||
run: npm run lint
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
53
.github/workflows/docker-publish.yml
vendored
Normal file
53
.github/workflows/docker-publish.yml
vendored
Normal file
@@ -0,0 +1,53 @@
|
||||
name: Docker Publish
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
IMAGE_NAME: ghcr.io/smile-qwq/subtracker-api
|
||||
|
||||
jobs:
|
||||
docker:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=ref,event=branch
|
||||
type=ref,event=tag
|
||||
type=sha
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
11
.gitignore
vendored
Normal file
11
.gitignore
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
node_modules
|
||||
dist
|
||||
coverage
|
||||
.env
|
||||
.env.local
|
||||
*.log
|
||||
apps/api/prisma/dev.db
|
||||
apps/api/prisma/dev.db-journal
|
||||
apps/api/prisma/test.db
|
||||
apps/api/prisma/test.db-journal
|
||||
apps/api/apps/api/storage/
|
||||
142
DEPLOYMENT.md
Normal file
142
DEPLOYMENT.md
Normal file
@@ -0,0 +1,142 @@
|
||||
# SubTracker Deployment
|
||||
|
||||
本文档面向服务器部署,默认前提:
|
||||
|
||||
- 前端静态文件由 **外部 Nginx** 托管
|
||||
- API 使用 Docker 镜像部署
|
||||
- SQLite 与 Logo 文件通过宿主机目录持久化
|
||||
|
||||
## 1. API 镜像约定
|
||||
|
||||
当前推荐镜像名:
|
||||
|
||||
```text
|
||||
ghcr.io/smile-qwq/subtracker-api:latest
|
||||
```
|
||||
|
||||
`docker-compose.yml` 使用环境变量注入镜像名:
|
||||
|
||||
```bash
|
||||
SUBTRACKER_API_IMAGE=ghcr.io/smile-qwq/subtracker-api:latest
|
||||
```
|
||||
|
||||
如果不传,Compose 也会默认使用上面的镜像地址。
|
||||
|
||||
## 2. 启动 API
|
||||
|
||||
在服务器目录准备好:
|
||||
|
||||
- `docker-compose.yml`
|
||||
- `data/`
|
||||
- `data/logos/`
|
||||
|
||||
然后执行:
|
||||
|
||||
```bash
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
## 3. 核心环境变量
|
||||
|
||||
Compose 中默认只保留这些部署变量:
|
||||
|
||||
- `SUBTRACKER_API_IMAGE`
|
||||
- `PORT`
|
||||
- `HOST`
|
||||
- `DATABASE_URL`
|
||||
- `WEB_ORIGIN`
|
||||
- `LOG_LEVEL`
|
||||
|
||||
示例:
|
||||
|
||||
```bash
|
||||
SUBTRACKER_API_IMAGE=ghcr.io/smile-qwq/subtracker-api:latest
|
||||
PORT=3001
|
||||
HOST=0.0.0.0
|
||||
DATABASE_URL=file:/app/data/subtracker.db
|
||||
WEB_ORIGIN=https://subtracker.example.com
|
||||
LOG_LEVEL=warn
|
||||
```
|
||||
|
||||
其余业务默认值继续由后端 `config.ts` 提供,不需要在生产环境全部展开。
|
||||
|
||||
## 4. 持久化目录
|
||||
|
||||
Compose 已挂载:
|
||||
|
||||
- `./data` → SQLite 数据库目录
|
||||
- `./data/logos` → 本地 Logo 文件目录
|
||||
|
||||
请确保宿主机目录可写。
|
||||
|
||||
## 5. 外部 Nginx 反代示例
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name subtracker.example.com;
|
||||
|
||||
root /var/www/subtracker;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:3001/api/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /static/logos/ {
|
||||
proxy_pass http://127.0.0.1:3001/static/logos/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 6. 前端部署
|
||||
|
||||
前端不进入 Docker。
|
||||
|
||||
推荐流程:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run build -w apps/web
|
||||
```
|
||||
|
||||
然后把 `apps/web/dist` 发布到外部 Nginx 静态目录。
|
||||
|
||||
## 7. GitHub Actions 与镜像发布
|
||||
|
||||
仓库已预置两个 workflow:
|
||||
|
||||
- `CI`:在 `main` 和 PR 上执行安装、Prisma Generate、Lint、Build
|
||||
- `Docker Publish`:在 `main`、`v*` tag 或手动触发时发布 API 镜像到 GHCR
|
||||
|
||||
默认镜像发布地址:
|
||||
|
||||
```text
|
||||
ghcr.io/smile-qwq/subtracker-api
|
||||
```
|
||||
|
||||
## 8. 升级流程
|
||||
|
||||
当 API 镜像发布完成后,服务器只需:
|
||||
|
||||
```bash
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
前端静态资源单独更新到 Nginx 目录即可。
|
||||
34
Dockerfile
Normal file
34
Dockerfile
Normal file
@@ -0,0 +1,34 @@
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
COPY apps/api/package.json apps/api/package.json
|
||||
COPY packages/shared/package.json packages/shared/package.json
|
||||
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN npm run prisma:generate -w apps/api \
|
||||
&& npm run build -w packages/shared \
|
||||
&& npm run build -w apps/api \
|
||||
&& npm prune --omit=dev
|
||||
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/packages/shared/dist ./packages/shared/dist
|
||||
COPY --from=builder /app/packages/shared/package.json ./packages/shared/package.json
|
||||
COPY --from=builder /app/apps/api/dist ./apps/api/dist
|
||||
COPY --from=builder /app/apps/api/package.json ./apps/api/package.json
|
||||
COPY --from=builder /app/apps/api/prisma ./apps/api/prisma
|
||||
|
||||
RUN mkdir -p /app/data /app/apps/api/storage/logos
|
||||
|
||||
EXPOSE 3001
|
||||
|
||||
CMD ["node", "apps/api/dist/index.js"]
|
||||
54
README.md
Normal file
54
README.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# SubTracker
|
||||
|
||||
SubTracker 是一个简洁但功能完整的个人订阅管理系统,用来统一管理多币种订阅、续费提醒、预算统计、Logo 与 AI 辅助录入。
|
||||
|
||||
## 功能亮点
|
||||
|
||||
- 订阅管理:新增、编辑、续费、暂停、停用、删除、拖拽排序
|
||||
- 多币种支持:自动汇率换算、基准货币切换、汇率转换器
|
||||
- 预算统计:月预算、年预算、分类预算、仪表盘总览
|
||||
- 通知能力:Webhook、SMTP 邮件、PushPlus
|
||||
- Logo 能力:上传、本地复用、网络搜索并保存到本地
|
||||
- AI 识别:支持文本或图片识别后自动填充订阅信息
|
||||
|
||||
## 技术栈
|
||||
|
||||
- 前端:Vue 3、Vite、TypeScript、Naive UI、Pinia、Vue Router、TanStack Query、ECharts
|
||||
- 后端:Fastify、Prisma、SQLite、Zod、node-cron
|
||||
|
||||
## 本地开发
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run prisma:generate
|
||||
npm run prisma:push
|
||||
npm run prisma:seed
|
||||
npm run dev
|
||||
```
|
||||
|
||||
默认地址:
|
||||
|
||||
- Web:`http://127.0.0.1:5173`
|
||||
- API:`http://localhost:3001`
|
||||
|
||||
默认账号:
|
||||
|
||||
- 用户名:`admin`
|
||||
- 密码:`admin`
|
||||
|
||||
## 部署
|
||||
|
||||
- 详细部署文档见:[DEPLOYMENT.md](./DEPLOYMENT.md)
|
||||
- 推荐方式:
|
||||
- 前端静态文件由外部 Nginx 托管
|
||||
- API 使用 Docker 镜像部署
|
||||
- Nginx 反代 `/api/` 和 `/static/logos/` 到 API
|
||||
|
||||
## 常用命令
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
npm run build
|
||||
npm run lint
|
||||
npm test
|
||||
```
|
||||
10
apps/api/.env.example
Normal file
10
apps/api/.env.example
Normal file
@@ -0,0 +1,10 @@
|
||||
PORT=3001
|
||||
HOST=0.0.0.0
|
||||
DATABASE_URL="file:./dev.db"
|
||||
WEB_ORIGIN="http://localhost:5173"
|
||||
BASE_CURRENCY=CNY
|
||||
DEFAULT_NOTIFY_DAYS=3
|
||||
EXCHANGE_RATE_PROVIDER=er-api
|
||||
EXCHANGE_RATE_URL=https://open.er-api.com/v6/latest
|
||||
CRON_SCAN=0 */3 * * *
|
||||
CRON_REFRESH_RATES=0 2 * * *
|
||||
37
apps/api/package.json
Normal file
37
apps/api/package.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "@subtracker/api",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsup src/index.ts --format esm --dts",
|
||||
"start": "node dist/index.js",
|
||||
"test": "vitest run",
|
||||
"lint": "tsc --noEmit",
|
||||
"prisma:generate": "prisma generate",
|
||||
"prisma:migrate": "prisma migrate dev --name init",
|
||||
"prisma:push": "prisma db push",
|
||||
"prisma:seed": "tsx prisma/seed.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^10.0.2",
|
||||
"@prisma/client": "^6.6.0",
|
||||
"@subtracker/shared": "0.1.0",
|
||||
"dayjs": "^1.11.13",
|
||||
"fastify": "^5.2.2",
|
||||
"node-cron": "^4.0.5",
|
||||
"nodemailer": "^8.0.5",
|
||||
"tesseract.js": "^7.0.0",
|
||||
"zod": "^3.24.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.14.1",
|
||||
"@types/nodemailer": "^8.0.0",
|
||||
"prisma": "^6.6.0",
|
||||
"tsup": "^8.4.0",
|
||||
"tsx": "^4.19.3",
|
||||
"typescript": "^5.8.3",
|
||||
"vitest": "^3.1.1"
|
||||
}
|
||||
}
|
||||
134
apps/api/prisma/schema.prisma
Normal file
134
apps/api/prisma/schema.prisma
Normal file
@@ -0,0 +1,134 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "sqlite"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
enum SubscriptionStatus {
|
||||
active
|
||||
paused
|
||||
cancelled
|
||||
expired
|
||||
}
|
||||
|
||||
enum BillingIntervalUnit {
|
||||
day
|
||||
week
|
||||
month
|
||||
quarter
|
||||
year
|
||||
}
|
||||
|
||||
enum WebhookDeliveryStatus {
|
||||
pending
|
||||
success
|
||||
failed
|
||||
}
|
||||
|
||||
model Category {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
color String @default("#3b82f6")
|
||||
icon String @default("apps-outline")
|
||||
sortOrder Int @default(0)
|
||||
subscriptions Subscription[]
|
||||
|
||||
@@unique([name])
|
||||
}
|
||||
|
||||
model Subscription {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
categoryId String?
|
||||
category Category? @relation(fields: [categoryId], references: [id], onDelete: SetNull)
|
||||
description String @default("")
|
||||
websiteUrl String?
|
||||
logoUrl String?
|
||||
logoSource String?
|
||||
logoFetchedAt DateTime?
|
||||
status SubscriptionStatus @default(active)
|
||||
amount Float
|
||||
currency String
|
||||
billingIntervalCount Int @default(1)
|
||||
billingIntervalUnit BillingIntervalUnit
|
||||
startDate DateTime
|
||||
nextRenewalDate DateTime
|
||||
notifyDaysBefore Int @default(3)
|
||||
webhookEnabled Boolean @default(true)
|
||||
notes String @default("")
|
||||
paymentRecords PaymentRecord[]
|
||||
deliveries WebhookDelivery[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([status, nextRenewalDate])
|
||||
}
|
||||
|
||||
model PaymentRecord {
|
||||
id String @id @default(cuid())
|
||||
subscriptionId String
|
||||
subscription Subscription @relation(fields: [subscriptionId], references: [id], onDelete: Cascade)
|
||||
amount Float
|
||||
currency String
|
||||
baseCurrency String
|
||||
convertedAmount Float
|
||||
exchangeRate Float
|
||||
paidAt DateTime
|
||||
periodStart DateTime
|
||||
periodEnd DateTime
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([subscriptionId, paidAt])
|
||||
}
|
||||
|
||||
model ExchangeRateSnapshot {
|
||||
id String @id @default(cuid())
|
||||
baseCurrency String @unique
|
||||
ratesJson Json
|
||||
provider String
|
||||
fetchedAt DateTime
|
||||
isStale Boolean @default(false)
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model WebhookEndpoint {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
url String
|
||||
secret String
|
||||
enabled Boolean @default(true)
|
||||
eventsJson Json
|
||||
deliveries WebhookDelivery[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model WebhookDelivery {
|
||||
id String @id @default(cuid())
|
||||
endpointId String
|
||||
endpoint WebhookEndpoint @relation(fields: [endpointId], references: [id], onDelete: Cascade)
|
||||
subscriptionId String?
|
||||
subscription Subscription? @relation(fields: [subscriptionId], references: [id], onDelete: SetNull)
|
||||
eventType String
|
||||
resourceKey String
|
||||
periodKey String
|
||||
payloadJson Json
|
||||
status WebhookDeliveryStatus @default(pending)
|
||||
responseCode Int?
|
||||
responseBody String?
|
||||
attemptCount Int @default(0)
|
||||
lastAttemptAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@unique([endpointId, eventType, resourceKey, periodKey])
|
||||
@@index([status, createdAt])
|
||||
}
|
||||
|
||||
model Setting {
|
||||
key String @id
|
||||
valueJson Json
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
42
apps/api/prisma/seed.ts
Normal file
42
apps/api/prisma/seed.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
|
||||
const prisma = new PrismaClient()
|
||||
|
||||
async function main() {
|
||||
await prisma.setting.upsert({
|
||||
where: { key: 'baseCurrency' },
|
||||
update: { valueJson: 'CNY' },
|
||||
create: { key: 'baseCurrency', valueJson: 'CNY' }
|
||||
})
|
||||
|
||||
await prisma.setting.upsert({
|
||||
where: { key: 'defaultNotifyDays' },
|
||||
update: { valueJson: 3 },
|
||||
create: { key: 'defaultNotifyDays', valueJson: 3 }
|
||||
})
|
||||
|
||||
const defaults = [
|
||||
{ name: '开发工具', color: '#4f46e5', icon: 'code-slash-outline', sortOrder: 1 },
|
||||
{ name: '影音娱乐', color: '#ef4444', icon: 'film-outline', sortOrder: 2 },
|
||||
{ name: '效率办公', color: '#10b981', icon: 'briefcase-outline', sortOrder: 3 }
|
||||
]
|
||||
|
||||
for (const item of defaults) {
|
||||
await prisma.category.upsert({
|
||||
where: { name: item.name },
|
||||
update: item,
|
||||
create: item
|
||||
})
|
||||
}
|
||||
|
||||
console.log('Seed completed.')
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((error) => {
|
||||
console.error(error)
|
||||
process.exit(1)
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect()
|
||||
})
|
||||
94
apps/api/src/app.ts
Normal file
94
apps/api/src/app.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import Fastify from 'fastify'
|
||||
import cors from '@fastify/cors'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { config } from './config'
|
||||
import { sendError } from './http'
|
||||
import { authRoutes } from './routes/auth'
|
||||
import { categoryRoutes } from './routes/categories'
|
||||
import { subscriptionRoutes } from './routes/subscriptions'
|
||||
import { statisticsRoutes } from './routes/statistics'
|
||||
import { calendarRoutes } from './routes/calendar'
|
||||
import { exchangeRateRoutes } from './routes/exchange-rates'
|
||||
import { webhookRoutes } from './routes/webhooks'
|
||||
import { settingsRoutes } from './routes/settings'
|
||||
import { notificationRoutes } from './routes/notifications'
|
||||
import { aiRoutes } from './routes/ai'
|
||||
import { verifyToken } from './services/auth.service'
|
||||
|
||||
export async function buildApp() {
|
||||
const app = Fastify({
|
||||
logger: {
|
||||
level: process.env.LOG_LEVEL ?? 'warn'
|
||||
}
|
||||
})
|
||||
|
||||
await app.register(cors, {
|
||||
origin: config.webOrigin
|
||||
})
|
||||
|
||||
app.get('/health', async () => ({ ok: true, timestamp: new Date().toISOString() }))
|
||||
|
||||
app.get('/static/logos/:filename', async (request, reply) => {
|
||||
const filename = (request.params as { filename: string }).filename
|
||||
const safeName = path.basename(filename)
|
||||
const filePath = path.resolve(process.cwd(), 'apps/api/storage/logos', safeName)
|
||||
const ext = path.extname(safeName).toLowerCase()
|
||||
const mimeMap: Record<string, string> = {
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.webp': 'image/webp',
|
||||
'.svg': 'image/svg+xml'
|
||||
}
|
||||
|
||||
try {
|
||||
const file = await readFile(filePath)
|
||||
reply.header('Content-Type', mimeMap[ext] ?? 'application/octet-stream')
|
||||
return reply.send(file)
|
||||
} catch {
|
||||
return sendError(reply, 404, 'not_found', 'Logo not found')
|
||||
}
|
||||
})
|
||||
|
||||
app.addHook('onRequest', async (request, reply) => {
|
||||
const url = request.url.split('?')[0]
|
||||
if (request.method === 'OPTIONS' || url === '/health' || url.startsWith('/static/logos/') || url === '/api/v1/auth/login') {
|
||||
return
|
||||
}
|
||||
|
||||
const authorization = request.headers.authorization
|
||||
const token = authorization?.startsWith('Bearer ') ? authorization.slice(7) : undefined
|
||||
const user = await verifyToken(token)
|
||||
|
||||
if (!user) {
|
||||
return sendError(reply, 401, 'unauthorized', '请先登录')
|
||||
}
|
||||
|
||||
request.auth = user
|
||||
})
|
||||
|
||||
await app.register(
|
||||
async (router) => {
|
||||
await authRoutes(router)
|
||||
await categoryRoutes(router)
|
||||
await subscriptionRoutes(router)
|
||||
await statisticsRoutes(router)
|
||||
await calendarRoutes(router)
|
||||
await exchangeRateRoutes(router)
|
||||
await webhookRoutes(router)
|
||||
await settingsRoutes(router)
|
||||
await notificationRoutes(router)
|
||||
await aiRoutes(router)
|
||||
},
|
||||
{ prefix: '/api/v1' }
|
||||
)
|
||||
|
||||
app.setErrorHandler((error, _request, reply) => {
|
||||
app.log.error(error)
|
||||
const message = error instanceof Error ? error.message : 'Unknown server error'
|
||||
return sendError(reply, 500, 'internal_error', message)
|
||||
})
|
||||
|
||||
return app
|
||||
}
|
||||
11
apps/api/src/config.ts
Normal file
11
apps/api/src/config.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export const config = {
|
||||
port: Number(process.env.PORT ?? 3001),
|
||||
host: process.env.HOST ?? '0.0.0.0',
|
||||
webOrigin: process.env.WEB_ORIGIN ?? 'http://localhost:5173',
|
||||
baseCurrency: (process.env.BASE_CURRENCY ?? 'CNY').toUpperCase(),
|
||||
defaultNotifyDays: Number(process.env.DEFAULT_NOTIFY_DAYS ?? 3),
|
||||
exchangeRateProvider: process.env.EXCHANGE_RATE_PROVIDER ?? 'er-api',
|
||||
exchangeRateUrl: process.env.EXCHANGE_RATE_URL ?? 'https://open.er-api.com/v6/latest',
|
||||
cronScan: process.env.CRON_SCAN ?? '0 */3 * * *',
|
||||
cronRefreshRates: process.env.CRON_REFRESH_RATES ?? '0 2 * * *'
|
||||
}
|
||||
3
apps/api/src/db.ts
Normal file
3
apps/api/src/db.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
|
||||
export const prisma = new PrismaClient()
|
||||
9
apps/api/src/fastify.d.ts
vendored
Normal file
9
apps/api/src/fastify.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
import 'fastify'
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyRequest {
|
||||
auth?: {
|
||||
username: string
|
||||
}
|
||||
}
|
||||
}
|
||||
19
apps/api/src/http.ts
Normal file
19
apps/api/src/http.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { FastifyReply } from 'fastify'
|
||||
|
||||
export function sendOk<T>(reply: FastifyReply, data: T, meta?: Record<string, unknown>) {
|
||||
return reply.status(200).send({ data, meta })
|
||||
}
|
||||
|
||||
export function sendCreated<T>(reply: FastifyReply, data: T) {
|
||||
return reply.status(201).send({ data })
|
||||
}
|
||||
|
||||
export function sendError(reply: FastifyReply, status: number, code: string, message: string, details?: unknown) {
|
||||
return reply.status(status).send({
|
||||
error: {
|
||||
code,
|
||||
message,
|
||||
details
|
||||
}
|
||||
})
|
||||
}
|
||||
24
apps/api/src/index.ts
Normal file
24
apps/api/src/index.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { buildApp } from './app'
|
||||
import { config } from './config'
|
||||
import { startSchedulers } from './services/scheduler.service'
|
||||
import { refreshExchangeRates } from './services/exchange-rate.service'
|
||||
|
||||
async function start() {
|
||||
const app = await buildApp()
|
||||
|
||||
try {
|
||||
await refreshExchangeRates()
|
||||
} catch (error) {
|
||||
console.warn('[api] 初始汇率刷新失败,将回退到已有快照。', error)
|
||||
}
|
||||
|
||||
startSchedulers()
|
||||
|
||||
await app.listen({ port: config.port, host: config.host })
|
||||
console.log(`[api] 已启动: http://${config.host}:${config.port}`)
|
||||
}
|
||||
|
||||
start().catch((error) => {
|
||||
console.error(error)
|
||||
process.exit(1)
|
||||
})
|
||||
41
apps/api/src/routes/ai.ts
Normal file
41
apps/api/src/routes/ai.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { FastifyInstance } from 'fastify'
|
||||
import { AiConfigSchema } from '@subtracker/shared'
|
||||
import { sendError, sendOk } from '../http'
|
||||
import { recognizeSubscriptionByAi, testAiConnection } from '../services/ai.service'
|
||||
|
||||
export async function aiRoutes(app: FastifyInstance) {
|
||||
app.post('/ai/test', async (request, reply) => {
|
||||
try {
|
||||
if (request.body) {
|
||||
const parsed = AiConfigSchema.partial().safeParse(request.body)
|
||||
if (!parsed.success) {
|
||||
return sendError(reply, 422, 'validation_error', 'Invalid AI config payload', parsed.error.flatten())
|
||||
}
|
||||
return sendOk(
|
||||
reply,
|
||||
await testAiConnection({
|
||||
enabled: parsed.data.enabled ?? false,
|
||||
providerName: parsed.data.providerName ?? 'Custom',
|
||||
baseUrl: parsed.data.baseUrl ?? '',
|
||||
apiKey: parsed.data.apiKey ?? '',
|
||||
model: parsed.data.model ?? '',
|
||||
timeoutMs: parsed.data.timeoutMs ?? 30000,
|
||||
promptTemplate: parsed.data.promptTemplate ?? ''
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
return sendOk(reply, await testAiConnection())
|
||||
} catch (error) {
|
||||
return sendError(reply, 400, 'ai_test_failed', error instanceof Error ? error.message : 'AI test failed')
|
||||
}
|
||||
})
|
||||
|
||||
app.post('/ai/recognize-subscription', async (request, reply) => {
|
||||
try {
|
||||
return sendOk(reply, await recognizeSubscriptionByAi(request.body))
|
||||
} catch (error) {
|
||||
return sendError(reply, 400, 'ai_recognition_failed', error instanceof Error ? error.message : 'AI recognition failed')
|
||||
}
|
||||
})
|
||||
}
|
||||
44
apps/api/src/routes/auth.ts
Normal file
44
apps/api/src/routes/auth.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { FastifyInstance } from 'fastify'
|
||||
import { ChangeCredentialsSchema, LoginSchema } from '@subtracker/shared'
|
||||
import { sendError, sendOk } from '../http'
|
||||
import { changeCredentials, loginWithCredentials } from '../services/auth.service'
|
||||
|
||||
export async function authRoutes(app: FastifyInstance) {
|
||||
app.post('/auth/login', async (request, reply) => {
|
||||
const parsed = LoginSchema.safeParse(request.body)
|
||||
if (!parsed.success) {
|
||||
return sendError(reply, 422, 'validation_error', 'Invalid login payload', parsed.error.flatten())
|
||||
}
|
||||
|
||||
const result = await loginWithCredentials(parsed.data.username, parsed.data.password)
|
||||
if (!result) {
|
||||
return sendError(reply, 401, 'invalid_credentials', '用户名或密码错误')
|
||||
}
|
||||
|
||||
return sendOk(reply, result)
|
||||
})
|
||||
|
||||
app.get('/auth/me', async (request, reply) => {
|
||||
if (!request.auth) {
|
||||
return sendError(reply, 401, 'unauthorized', '请先登录')
|
||||
}
|
||||
|
||||
return sendOk(reply, {
|
||||
user: request.auth
|
||||
})
|
||||
})
|
||||
|
||||
app.post('/auth/change-credentials', async (request, reply) => {
|
||||
const parsed = ChangeCredentialsSchema.safeParse(request.body)
|
||||
if (!parsed.success) {
|
||||
return sendError(reply, 422, 'validation_error', 'Invalid credentials payload', parsed.error.flatten())
|
||||
}
|
||||
|
||||
const result = await changeCredentials(parsed.data)
|
||||
if (!result) {
|
||||
return sendError(reply, 401, 'invalid_credentials', '原用户名或原密码错误')
|
||||
}
|
||||
|
||||
return sendOk(reply, result)
|
||||
})
|
||||
}
|
||||
49
apps/api/src/routes/calendar.ts
Normal file
49
apps/api/src/routes/calendar.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import dayjs from 'dayjs'
|
||||
import { FastifyInstance } from 'fastify'
|
||||
import { z } from 'zod'
|
||||
import { prisma } from '../db'
|
||||
import { sendOk, sendError } from '../http'
|
||||
import { ensureExchangeRates, getBaseCurrency } from '../services/exchange-rate.service'
|
||||
import { convertAmount } from '../utils/money'
|
||||
|
||||
export async function calendarRoutes(app: FastifyInstance) {
|
||||
app.get('/calendar/events', async (request, reply) => {
|
||||
const querySchema = z.object({
|
||||
start: z.string().date().optional(),
|
||||
end: z.string().date().optional()
|
||||
})
|
||||
|
||||
const parsed = querySchema.safeParse(request.query)
|
||||
if (!parsed.success) {
|
||||
return sendError(reply, 422, 'validation_error', 'Invalid query', parsed.error.flatten())
|
||||
}
|
||||
|
||||
const start = parsed.data.start ? dayjs(parsed.data.start).startOf('day').toDate() : dayjs().startOf('month').toDate()
|
||||
const end = parsed.data.end ? dayjs(parsed.data.end).endOf('day').toDate() : dayjs().endOf('month').toDate()
|
||||
|
||||
const subscriptions = await prisma.subscription.findMany({
|
||||
where: {
|
||||
nextRenewalDate: {
|
||||
gte: start,
|
||||
lte: end
|
||||
}
|
||||
},
|
||||
orderBy: { nextRenewalDate: 'asc' }
|
||||
})
|
||||
|
||||
const baseCurrency = await getBaseCurrency()
|
||||
const rates = await ensureExchangeRates(baseCurrency)
|
||||
|
||||
const events = subscriptions.map((item) => ({
|
||||
id: item.id,
|
||||
title: item.name,
|
||||
date: item.nextRenewalDate.toISOString(),
|
||||
currency: item.currency,
|
||||
amount: item.amount,
|
||||
convertedAmount: convertAmount(item.amount, item.currency, baseCurrency, rates.baseCurrency, rates.rates),
|
||||
status: item.status
|
||||
}))
|
||||
|
||||
return sendOk(reply, events)
|
||||
})
|
||||
}
|
||||
81
apps/api/src/routes/categories.ts
Normal file
81
apps/api/src/routes/categories.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { FastifyInstance } from 'fastify'
|
||||
import { z } from 'zod'
|
||||
import { prisma } from '../db'
|
||||
import { sendCreated, sendError, sendOk } from '../http'
|
||||
import { CategorySchema } from '@subtracker/shared'
|
||||
|
||||
export async function categoryRoutes(app: FastifyInstance) {
|
||||
app.get('/categories', async (_, reply) => {
|
||||
const categories = await prisma.category.findMany({ orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }] })
|
||||
return sendOk(reply, categories)
|
||||
})
|
||||
|
||||
app.post('/categories', async (request, reply) => {
|
||||
const parsed = CategorySchema.safeParse(request.body)
|
||||
if (!parsed.success) {
|
||||
return sendError(reply, 422, 'validation_error', 'Invalid category payload', parsed.error.flatten())
|
||||
}
|
||||
|
||||
try {
|
||||
const created = await prisma.category.create({
|
||||
data: {
|
||||
name: parsed.data.name,
|
||||
color: parsed.data.color,
|
||||
icon: parsed.data.icon,
|
||||
sortOrder: parsed.data.sortOrder
|
||||
}
|
||||
})
|
||||
return sendCreated(reply, created)
|
||||
} catch (error) {
|
||||
return sendError(reply, 409, 'conflict', 'Category name already exists', error)
|
||||
}
|
||||
})
|
||||
|
||||
app.patch('/categories/:id', async (request, reply) => {
|
||||
const params = z.object({ id: z.string() }).safeParse(request.params)
|
||||
if (!params.success) {
|
||||
return sendError(reply, 422, 'validation_error', 'Invalid category id')
|
||||
}
|
||||
|
||||
const parsed = CategorySchema.partial()
|
||||
.refine((value) => Object.keys(value).length > 0, 'Empty update payload')
|
||||
.safeParse(request.body)
|
||||
|
||||
if (!parsed.success) {
|
||||
return sendError(reply, 422, 'validation_error', 'Invalid category payload', parsed.error.flatten())
|
||||
}
|
||||
|
||||
try {
|
||||
const updated = await prisma.category.update({
|
||||
where: { id: params.data.id },
|
||||
data: {
|
||||
...(parsed.data.name !== undefined ? { name: parsed.data.name } : {}),
|
||||
...(parsed.data.color !== undefined ? { color: parsed.data.color } : {}),
|
||||
...(parsed.data.icon !== undefined ? { icon: parsed.data.icon } : {}),
|
||||
...(parsed.data.sortOrder !== undefined ? { sortOrder: parsed.data.sortOrder } : {})
|
||||
}
|
||||
})
|
||||
|
||||
return sendOk(reply, updated)
|
||||
} catch (error) {
|
||||
return sendError(reply, 409, 'conflict', 'Category update failed', error)
|
||||
}
|
||||
})
|
||||
|
||||
app.delete('/categories/:id', async (request, reply) => {
|
||||
const params = z.object({ id: z.string() }).safeParse(request.params)
|
||||
if (!params.success) {
|
||||
return sendError(reply, 422, 'validation_error', 'Invalid category id')
|
||||
}
|
||||
|
||||
try {
|
||||
await prisma.category.delete({
|
||||
where: { id: params.data.id }
|
||||
})
|
||||
|
||||
return sendOk(reply, { id: params.data.id, deleted: true })
|
||||
} catch (error) {
|
||||
return sendError(reply, 404, 'not_found', 'Category not found', error)
|
||||
}
|
||||
})
|
||||
}
|
||||
20
apps/api/src/routes/exchange-rates.ts
Normal file
20
apps/api/src/routes/exchange-rates.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { FastifyInstance } from 'fastify'
|
||||
import { sendError, sendOk } from '../http'
|
||||
import { ensureExchangeRates, getLatestSnapshot, refreshExchangeRates } from '../services/exchange-rate.service'
|
||||
|
||||
export async function exchangeRateRoutes(app: FastifyInstance) {
|
||||
app.get('/exchange-rates/latest', async (_, reply) => {
|
||||
const snapshot = await getLatestSnapshot()
|
||||
return sendOk(reply, snapshot)
|
||||
})
|
||||
|
||||
app.post('/exchange-rates/refresh', async (_, reply) => {
|
||||
try {
|
||||
await refreshExchangeRates()
|
||||
const latest = await ensureExchangeRates()
|
||||
return sendOk(reply, latest)
|
||||
} catch (error) {
|
||||
return sendError(reply, 500, 'refresh_failed', 'Failed to refresh exchange rates', error)
|
||||
}
|
||||
})
|
||||
}
|
||||
115
apps/api/src/routes/notifications.ts
Normal file
115
apps/api/src/routes/notifications.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { FastifyInstance } from 'fastify'
|
||||
import { z } from 'zod'
|
||||
import { sendError, sendOk } from '../http'
|
||||
import { EmailConfigSchema, PushPlusConfigSchema } from '@subtracker/shared'
|
||||
import {
|
||||
sendTestEmailNotification,
|
||||
sendTestEmailNotificationWithConfig,
|
||||
sendTestPushplusNotification,
|
||||
sendTestPushplusNotificationWithConfig
|
||||
} from '../services/channel-notification.service'
|
||||
import {
|
||||
getPrimaryWebhookEndpoint,
|
||||
sendTestWebhookNotification,
|
||||
sendTestWebhookNotificationWithConfig,
|
||||
upsertPrimaryWebhookEndpoint
|
||||
} from '../services/webhook.service'
|
||||
|
||||
const WebhookSettingsSchema = z.object({
|
||||
url: z.string().trim().max(500).default(''),
|
||||
secret: z.string().trim().max(200).default(''),
|
||||
enabled: z.boolean().default(false)
|
||||
})
|
||||
|
||||
export async function notificationRoutes(app: FastifyInstance) {
|
||||
app.get('/notifications/webhook', async (_, reply) => {
|
||||
const current = await getPrimaryWebhookEndpoint()
|
||||
return sendOk(reply, {
|
||||
id: current?.id ?? '',
|
||||
enabled: current?.enabled ?? false,
|
||||
url: current?.url ?? '',
|
||||
secret: current?.secret ?? ''
|
||||
})
|
||||
})
|
||||
|
||||
app.put('/notifications/webhook', async (request, reply) => {
|
||||
const parsed = WebhookSettingsSchema.safeParse(request.body)
|
||||
if (!parsed.success) {
|
||||
return sendError(reply, 422, 'validation_error', 'Invalid webhook settings payload', parsed.error.flatten())
|
||||
}
|
||||
|
||||
if (parsed.data.enabled && (!parsed.data.url || !parsed.data.secret)) {
|
||||
return sendError(reply, 422, 'validation_error', '启用 Webhook 时必须填写 URL 和 Secret')
|
||||
}
|
||||
|
||||
const saved = await upsertPrimaryWebhookEndpoint(parsed.data)
|
||||
return sendOk(reply, {
|
||||
id: saved.id,
|
||||
enabled: saved.enabled,
|
||||
url: saved.url,
|
||||
secret: saved.secret
|
||||
})
|
||||
})
|
||||
|
||||
app.post('/notifications/test/email', async (request, reply) => {
|
||||
try {
|
||||
if (request.body) {
|
||||
const parsed = EmailConfigSchema.partial().safeParse(request.body)
|
||||
if (!parsed.success) {
|
||||
return sendError(reply, 422, 'validation_error', 'Invalid email config payload', parsed.error.flatten())
|
||||
}
|
||||
await sendTestEmailNotificationWithConfig({
|
||||
host: parsed.data.host ?? '',
|
||||
port: parsed.data.port ?? 587,
|
||||
secure: parsed.data.secure ?? false,
|
||||
username: parsed.data.username ?? '',
|
||||
password: parsed.data.password ?? '',
|
||||
from: parsed.data.from ?? '',
|
||||
to: parsed.data.to ?? ''
|
||||
})
|
||||
} else {
|
||||
await sendTestEmailNotification()
|
||||
}
|
||||
return sendOk(reply, { success: true })
|
||||
} catch (error) {
|
||||
return sendError(reply, 400, 'email_test_failed', error instanceof Error ? error.message : 'Email test failed')
|
||||
}
|
||||
})
|
||||
|
||||
app.post('/notifications/test/pushplus', async (request, reply) => {
|
||||
try {
|
||||
if (request.body) {
|
||||
const parsed = PushPlusConfigSchema.partial().safeParse(request.body)
|
||||
if (!parsed.success) {
|
||||
return sendError(reply, 422, 'validation_error', 'Invalid PushPlus config payload', parsed.error.flatten())
|
||||
}
|
||||
await sendTestPushplusNotificationWithConfig({
|
||||
token: parsed.data.token ?? '',
|
||||
topic: parsed.data.topic ?? ''
|
||||
})
|
||||
} else {
|
||||
await sendTestPushplusNotification()
|
||||
}
|
||||
return sendOk(reply, { success: true })
|
||||
} catch (error) {
|
||||
return sendError(reply, 400, 'pushplus_test_failed', error instanceof Error ? error.message : 'PushPlus test failed')
|
||||
}
|
||||
})
|
||||
|
||||
app.post('/notifications/test/webhook', async (request, reply) => {
|
||||
try {
|
||||
if (request.body) {
|
||||
const parsed = WebhookSettingsSchema.safeParse(request.body)
|
||||
if (!parsed.success) {
|
||||
return sendError(reply, 422, 'validation_error', 'Invalid webhook settings payload', parsed.error.flatten())
|
||||
}
|
||||
await sendTestWebhookNotificationWithConfig(parsed.data)
|
||||
} else {
|
||||
await sendTestWebhookNotification()
|
||||
}
|
||||
return sendOk(reply, { success: true })
|
||||
} catch (error) {
|
||||
return sendError(reply, 400, 'webhook_test_failed', error instanceof Error ? error.message : 'Webhook test failed')
|
||||
}
|
||||
})
|
||||
}
|
||||
27
apps/api/src/routes/settings.ts
Normal file
27
apps/api/src/routes/settings.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { FastifyInstance } from 'fastify'
|
||||
import { sendError, sendOk } from '../http'
|
||||
import { getAppSettings, setSetting } from '../services/settings.service'
|
||||
import { SettingsSchema } from '@subtracker/shared'
|
||||
|
||||
export async function settingsRoutes(app: FastifyInstance) {
|
||||
app.get('/settings', async (_, reply) => {
|
||||
const settings = await getAppSettings()
|
||||
return sendOk(reply, settings)
|
||||
})
|
||||
|
||||
app.patch('/settings', async (request, reply) => {
|
||||
const parsed = SettingsSchema.partial().safeParse(request.body)
|
||||
if (!parsed.success) {
|
||||
return sendError(reply, 422, 'validation_error', 'Invalid settings payload', parsed.error.flatten())
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
Object.entries(parsed.data).map(([key, value]) => {
|
||||
return value === undefined ? Promise.resolve() : setSetting(key, value)
|
||||
})
|
||||
)
|
||||
|
||||
const settings = await getAppSettings()
|
||||
return sendOk(reply, settings)
|
||||
})
|
||||
}
|
||||
10
apps/api/src/routes/statistics.ts
Normal file
10
apps/api/src/routes/statistics.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { FastifyInstance } from 'fastify'
|
||||
import { sendOk } from '../http'
|
||||
import { getOverviewStatistics } from '../services/statistics.service'
|
||||
|
||||
export async function statisticsRoutes(app: FastifyInstance) {
|
||||
app.get('/statistics/overview', async (_, reply) => {
|
||||
const overview = await getOverviewStatistics()
|
||||
return sendOk(reply, overview)
|
||||
})
|
||||
}
|
||||
345
apps/api/src/routes/subscriptions.ts
Normal file
345
apps/api/src/routes/subscriptions.ts
Normal file
@@ -0,0 +1,345 @@
|
||||
import dayjs from 'dayjs'
|
||||
import { FastifyInstance } from 'fastify'
|
||||
import { z } from 'zod'
|
||||
import { prisma } from '../db'
|
||||
import { sendCreated, sendError, sendOk } from '../http'
|
||||
import {
|
||||
CreateSubscriptionSchema,
|
||||
LogoSearchSchema,
|
||||
LogoUploadSchema,
|
||||
RenewSubscriptionSchema,
|
||||
UpdateSubscriptionSchema
|
||||
} from '@subtracker/shared'
|
||||
import {
|
||||
appendSubscriptionOrder,
|
||||
removeSubscriptionOrder,
|
||||
setSubscriptionOrder,
|
||||
sortSubscriptionsByOrder
|
||||
} from '../services/subscription-order.service'
|
||||
import { renewSubscription } from '../services/subscription.service'
|
||||
import { dispatchNotificationEvent } from '../services/channel-notification.service'
|
||||
import {
|
||||
deleteLocalLogoFromLibrary,
|
||||
getLocalLogoLibrary,
|
||||
importRemoteLogo,
|
||||
normalizeLogoForStorage,
|
||||
saveUploadedLogo,
|
||||
searchSubscriptionLogos
|
||||
} from '../services/logo.service'
|
||||
|
||||
export async function subscriptionRoutes(app: FastifyInstance) {
|
||||
app.post('/subscriptions/logo/search', async (request, reply) => {
|
||||
const parsed = LogoSearchSchema.safeParse(request.body)
|
||||
if (!parsed.success) {
|
||||
return sendError(reply, 422, 'validation_error', 'Invalid logo search payload', parsed.error.flatten())
|
||||
}
|
||||
|
||||
return sendOk(reply, await searchSubscriptionLogos(parsed.data))
|
||||
})
|
||||
|
||||
app.get('/subscriptions/logo/library', async (_request, reply) => {
|
||||
return sendOk(reply, await getLocalLogoLibrary())
|
||||
})
|
||||
|
||||
app.delete('/subscriptions/logo/library/:filename', async (request, reply) => {
|
||||
const parsed = z
|
||||
.object({
|
||||
filename: z.string().min(1).max(255)
|
||||
})
|
||||
.safeParse(request.params)
|
||||
|
||||
if (!parsed.success) {
|
||||
return sendError(reply, 422, 'validation_error', 'Invalid logo filename', parsed.error.flatten())
|
||||
}
|
||||
|
||||
try {
|
||||
return sendOk(reply, await deleteLocalLogoFromLibrary(parsed.data.filename))
|
||||
} catch (error) {
|
||||
return sendError(reply, 400, 'logo_delete_failed', error instanceof Error ? error.message : 'Logo delete failed')
|
||||
}
|
||||
})
|
||||
|
||||
app.post('/subscriptions/logo/upload', async (request, reply) => {
|
||||
const parsed = LogoUploadSchema.safeParse(request.body)
|
||||
if (!parsed.success) {
|
||||
return sendError(reply, 422, 'validation_error', 'Invalid logo upload payload', parsed.error.flatten())
|
||||
}
|
||||
|
||||
try {
|
||||
return sendOk(reply, await saveUploadedLogo(parsed.data))
|
||||
} catch (error) {
|
||||
return sendError(reply, 400, 'logo_upload_failed', error instanceof Error ? error.message : 'Logo upload failed')
|
||||
}
|
||||
})
|
||||
|
||||
app.post('/subscriptions/logo/import', async (request, reply) => {
|
||||
const parsed = z
|
||||
.object({
|
||||
logoUrl: z.string().url(),
|
||||
source: z.string().max(100).optional()
|
||||
})
|
||||
.safeParse(request.body)
|
||||
|
||||
if (!parsed.success) {
|
||||
return sendError(reply, 422, 'validation_error', 'Invalid logo import payload', parsed.error.flatten())
|
||||
}
|
||||
|
||||
try {
|
||||
return sendOk(reply, await importRemoteLogo(parsed.data))
|
||||
} catch (error) {
|
||||
return sendError(reply, 400, 'logo_import_failed', error instanceof Error ? error.message : 'Logo import failed')
|
||||
}
|
||||
})
|
||||
|
||||
app.get('/subscriptions', async (request, reply) => {
|
||||
const querySchema = z.object({
|
||||
q: z.string().optional(),
|
||||
status: z.string().optional(),
|
||||
categoryId: z.string().optional()
|
||||
})
|
||||
|
||||
const parsed = querySchema.safeParse(request.query)
|
||||
if (!parsed.success) {
|
||||
return sendError(reply, 422, 'validation_error', 'Invalid query', parsed.error.flatten())
|
||||
}
|
||||
|
||||
const where: Record<string, unknown> = {}
|
||||
if (parsed.data.q) {
|
||||
where.OR = [{ name: { contains: parsed.data.q } }, { description: { contains: parsed.data.q } }]
|
||||
}
|
||||
if (parsed.data.status) where.status = parsed.data.status
|
||||
if (parsed.data.categoryId) where.categoryId = parsed.data.categoryId
|
||||
|
||||
const rows = await prisma.subscription.findMany({
|
||||
where,
|
||||
include: { category: true },
|
||||
orderBy: [{ createdAt: 'asc' }]
|
||||
})
|
||||
|
||||
return sendOk(reply, await sortSubscriptionsByOrder(rows))
|
||||
})
|
||||
|
||||
app.post('/subscriptions/reorder', async (request, reply) => {
|
||||
const parsed = z
|
||||
.object({
|
||||
ids: z.array(z.string()).min(1)
|
||||
})
|
||||
.safeParse(request.body)
|
||||
|
||||
if (!parsed.success) {
|
||||
return sendError(reply, 422, 'validation_error', 'Invalid reorder payload', parsed.error.flatten())
|
||||
}
|
||||
|
||||
await setSubscriptionOrder(parsed.data.ids)
|
||||
return sendOk(reply, { success: true })
|
||||
})
|
||||
|
||||
app.get('/subscriptions/:id', async (request, reply) => {
|
||||
const params = z.object({ id: z.string() }).safeParse(request.params)
|
||||
if (!params.success) {
|
||||
return sendError(reply, 422, 'validation_error', 'Invalid subscription id')
|
||||
}
|
||||
|
||||
const row = await prisma.subscription.findUnique({
|
||||
where: { id: params.data.id },
|
||||
include: {
|
||||
category: true
|
||||
}
|
||||
})
|
||||
|
||||
if (!row) {
|
||||
return sendError(reply, 404, 'not_found', 'Subscription not found')
|
||||
}
|
||||
|
||||
return sendOk(reply, row)
|
||||
})
|
||||
|
||||
app.post('/subscriptions', async (request, reply) => {
|
||||
const parsed = CreateSubscriptionSchema.safeParse(request.body)
|
||||
if (!parsed.success) {
|
||||
return sendError(reply, 422, 'validation_error', 'Invalid subscription payload', parsed.error.flatten())
|
||||
}
|
||||
|
||||
let normalizedLogo
|
||||
try {
|
||||
normalizedLogo = await normalizeLogoForStorage({
|
||||
logoUrl: parsed.data.logoUrl ?? null,
|
||||
logoSource: parsed.data.logoSource ?? null
|
||||
})
|
||||
} catch (error) {
|
||||
return sendError(reply, 400, 'logo_import_failed', error instanceof Error ? error.message : 'Logo import failed')
|
||||
}
|
||||
|
||||
const created = await prisma.subscription.create({
|
||||
data: {
|
||||
name: parsed.data.name,
|
||||
categoryId: parsed.data.categoryId ?? null,
|
||||
description: parsed.data.description,
|
||||
amount: parsed.data.amount,
|
||||
currency: parsed.data.currency,
|
||||
billingIntervalCount: parsed.data.billingIntervalCount,
|
||||
billingIntervalUnit: parsed.data.billingIntervalUnit,
|
||||
startDate: dayjs(parsed.data.startDate).toDate(),
|
||||
nextRenewalDate: dayjs(parsed.data.nextRenewalDate).toDate(),
|
||||
notifyDaysBefore: parsed.data.notifyDaysBefore,
|
||||
webhookEnabled: parsed.data.webhookEnabled,
|
||||
notes: parsed.data.notes,
|
||||
websiteUrl: parsed.data.websiteUrl ?? null,
|
||||
logoUrl: normalizedLogo.logoUrl,
|
||||
logoSource: normalizedLogo.logoSource,
|
||||
logoFetchedAt: normalizedLogo.logoFetchedAt
|
||||
},
|
||||
include: { category: true }
|
||||
})
|
||||
|
||||
await appendSubscriptionOrder(created.id)
|
||||
return sendCreated(reply, created)
|
||||
})
|
||||
|
||||
app.patch('/subscriptions/:id', async (request, reply) => {
|
||||
const params = z.object({ id: z.string() }).safeParse(request.params)
|
||||
if (!params.success) {
|
||||
return sendError(reply, 422, 'validation_error', 'Invalid subscription id')
|
||||
}
|
||||
|
||||
const parsed = UpdateSubscriptionSchema.safeParse(request.body)
|
||||
if (!parsed.success) {
|
||||
return sendError(reply, 422, 'validation_error', 'Invalid update payload', parsed.error.flatten())
|
||||
}
|
||||
|
||||
const payload = parsed.data
|
||||
|
||||
try {
|
||||
const normalizedLogo =
|
||||
payload.logoUrl !== undefined || payload.logoSource !== undefined
|
||||
? await normalizeLogoForStorage({
|
||||
logoUrl: payload.logoUrl ?? null,
|
||||
logoSource: payload.logoSource ?? null
|
||||
})
|
||||
: null
|
||||
|
||||
const updated = await prisma.subscription.update({
|
||||
where: { id: params.data.id },
|
||||
data: {
|
||||
...(payload.name !== undefined ? { name: payload.name } : {}),
|
||||
...(payload.categoryId !== undefined ? { categoryId: payload.categoryId } : {}),
|
||||
...(payload.description !== undefined ? { description: payload.description } : {}),
|
||||
...(payload.status !== undefined ? { status: payload.status } : {}),
|
||||
...(payload.amount !== undefined ? { amount: payload.amount } : {}),
|
||||
...(payload.currency !== undefined ? { currency: payload.currency } : {}),
|
||||
...(payload.billingIntervalCount !== undefined ? { billingIntervalCount: payload.billingIntervalCount } : {}),
|
||||
...(payload.billingIntervalUnit !== undefined ? { billingIntervalUnit: payload.billingIntervalUnit } : {}),
|
||||
...(payload.startDate !== undefined ? { startDate: dayjs(payload.startDate).toDate() } : {}),
|
||||
...(payload.nextRenewalDate !== undefined ? { nextRenewalDate: dayjs(payload.nextRenewalDate).toDate() } : {}),
|
||||
...(payload.notifyDaysBefore !== undefined ? { notifyDaysBefore: payload.notifyDaysBefore } : {}),
|
||||
...(payload.webhookEnabled !== undefined ? { webhookEnabled: payload.webhookEnabled } : {}),
|
||||
...(payload.notes !== undefined ? { notes: payload.notes } : {}),
|
||||
...(payload.websiteUrl !== undefined ? { websiteUrl: payload.websiteUrl } : {}),
|
||||
...(normalizedLogo
|
||||
? {
|
||||
logoUrl: normalizedLogo.logoUrl,
|
||||
logoSource: normalizedLogo.logoSource,
|
||||
logoFetchedAt: normalizedLogo.logoFetchedAt
|
||||
}
|
||||
: {})
|
||||
},
|
||||
include: { category: true }
|
||||
})
|
||||
|
||||
return sendOk(reply, updated)
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('Logo')) {
|
||||
return sendError(reply, 400, 'logo_import_failed', error.message)
|
||||
}
|
||||
return sendError(reply, 404, 'not_found', 'Subscription not found')
|
||||
}
|
||||
})
|
||||
|
||||
app.post('/subscriptions/:id/renew', async (request, reply) => {
|
||||
const params = z.object({ id: z.string() }).safeParse(request.params)
|
||||
if (!params.success) {
|
||||
return sendError(reply, 422, 'validation_error', 'Invalid subscription id')
|
||||
}
|
||||
|
||||
const parsed = RenewSubscriptionSchema.safeParse(request.body ?? {})
|
||||
if (!parsed.success) {
|
||||
return sendError(reply, 422, 'validation_error', 'Invalid renew payload', parsed.error.flatten())
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await renewSubscription(
|
||||
params.data.id,
|
||||
parsed.data.paidAt ? dayjs(parsed.data.paidAt).toDate() : undefined,
|
||||
parsed.data.amount,
|
||||
parsed.data.currency
|
||||
)
|
||||
|
||||
await dispatchNotificationEvent({
|
||||
eventType: 'subscription.renewed',
|
||||
resourceKey: `subscription:${params.data.id}`,
|
||||
periodKey: dayjs(result.payment.paidAt).format('YYYY-MM-DD'),
|
||||
subscriptionId: params.data.id,
|
||||
payload: {
|
||||
subscriptionId: params.data.id,
|
||||
paymentId: result.payment.id,
|
||||
amount: result.payment.amount,
|
||||
currency: result.payment.currency,
|
||||
convertedAmount: result.payment.convertedAmount,
|
||||
baseCurrency: result.payment.baseCurrency,
|
||||
paidAt: result.payment.paidAt.toISOString(),
|
||||
nextRenewalDate: result.subscription.nextRenewalDate.toISOString()
|
||||
}
|
||||
})
|
||||
|
||||
return sendOk(reply, result)
|
||||
} catch (error) {
|
||||
return sendError(reply, 404, 'not_found', error instanceof Error ? error.message : 'Renew failed')
|
||||
}
|
||||
})
|
||||
|
||||
app.post('/subscriptions/:id/pause', async (request, reply) => {
|
||||
const params = z.object({ id: z.string() }).safeParse(request.params)
|
||||
if (!params.success) {
|
||||
return sendError(reply, 422, 'validation_error', 'Invalid subscription id')
|
||||
}
|
||||
|
||||
const updated = await prisma.subscription.update({
|
||||
where: { id: params.data.id },
|
||||
data: { status: 'paused' }
|
||||
})
|
||||
|
||||
return sendOk(reply, updated)
|
||||
})
|
||||
|
||||
app.post('/subscriptions/:id/cancel', async (request, reply) => {
|
||||
const params = z.object({ id: z.string() }).safeParse(request.params)
|
||||
if (!params.success) {
|
||||
return sendError(reply, 422, 'validation_error', 'Invalid subscription id')
|
||||
}
|
||||
|
||||
const updated = await prisma.subscription.update({
|
||||
where: { id: params.data.id },
|
||||
data: { status: 'cancelled' }
|
||||
})
|
||||
|
||||
return sendOk(reply, updated)
|
||||
})
|
||||
|
||||
app.delete('/subscriptions/:id', async (request, reply) => {
|
||||
const params = z.object({ id: z.string() }).safeParse(request.params)
|
||||
if (!params.success) {
|
||||
return sendError(reply, 422, 'validation_error', 'Invalid subscription id')
|
||||
}
|
||||
|
||||
try {
|
||||
await prisma.subscription.delete({
|
||||
where: { id: params.data.id }
|
||||
})
|
||||
|
||||
await removeSubscriptionOrder(params.data.id)
|
||||
return sendOk(reply, { id: params.data.id, deleted: true })
|
||||
} catch {
|
||||
return sendError(reply, 404, 'not_found', 'Subscription not found')
|
||||
}
|
||||
})
|
||||
}
|
||||
76
apps/api/src/routes/webhooks.ts
Normal file
76
apps/api/src/routes/webhooks.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { FastifyInstance } from 'fastify'
|
||||
import { z } from 'zod'
|
||||
import { prisma } from '../db'
|
||||
import { sendCreated, sendError, sendOk } from '../http'
|
||||
import { CreateWebhookEndpointSchema, UpdateWebhookEndpointSchema } from '@subtracker/shared'
|
||||
import { listWebhookDeliveries } from '../services/webhook.service'
|
||||
|
||||
export async function webhookRoutes(app: FastifyInstance) {
|
||||
app.get('/webhooks', async (_, reply) => {
|
||||
const rows = await prisma.webhookEndpoint.findMany({ orderBy: { createdAt: 'desc' } })
|
||||
return sendOk(reply, rows)
|
||||
})
|
||||
|
||||
app.post('/webhooks', async (request, reply) => {
|
||||
const parsed = CreateWebhookEndpointSchema.safeParse(request.body)
|
||||
if (!parsed.success) {
|
||||
return sendError(reply, 422, 'validation_error', 'Invalid webhook payload', parsed.error.flatten())
|
||||
}
|
||||
|
||||
const created = await prisma.webhookEndpoint.create({
|
||||
data: {
|
||||
name: parsed.data.name,
|
||||
url: parsed.data.url,
|
||||
secret: parsed.data.secret,
|
||||
enabled: parsed.data.enabled,
|
||||
eventsJson: parsed.data.events
|
||||
}
|
||||
})
|
||||
|
||||
return sendCreated(reply, created)
|
||||
})
|
||||
|
||||
app.patch('/webhooks/:id', async (request, reply) => {
|
||||
const params = z.object({ id: z.string() }).safeParse(request.params)
|
||||
if (!params.success) {
|
||||
return sendError(reply, 422, 'validation_error', 'Invalid webhook id')
|
||||
}
|
||||
|
||||
const parsed = UpdateWebhookEndpointSchema.safeParse(request.body)
|
||||
if (!parsed.success) {
|
||||
return sendError(reply, 422, 'validation_error', 'Invalid webhook payload', parsed.error.flatten())
|
||||
}
|
||||
|
||||
const payload = parsed.data
|
||||
|
||||
try {
|
||||
const updated = await prisma.webhookEndpoint.update({
|
||||
where: { id: params.data.id },
|
||||
data: {
|
||||
...(payload.name !== undefined ? { name: payload.name } : {}),
|
||||
...(payload.url !== undefined ? { url: payload.url } : {}),
|
||||
...(payload.secret !== undefined ? { secret: payload.secret } : {}),
|
||||
...(payload.enabled !== undefined ? { enabled: payload.enabled } : {}),
|
||||
...(payload.events !== undefined ? { eventsJson: payload.events } : {})
|
||||
}
|
||||
})
|
||||
return sendOk(reply, updated)
|
||||
} catch {
|
||||
return sendError(reply, 404, 'not_found', 'Webhook not found')
|
||||
}
|
||||
})
|
||||
|
||||
app.get('/webhook-deliveries', async (request, reply) => {
|
||||
const querySchema = z.object({
|
||||
limit: z.coerce.number().int().min(1).max(500).default(100)
|
||||
})
|
||||
|
||||
const parsed = querySchema.safeParse(request.query)
|
||||
if (!parsed.success) {
|
||||
return sendError(reply, 422, 'validation_error', 'Invalid query', parsed.error.flatten())
|
||||
}
|
||||
|
||||
const deliveries = await listWebhookDeliveries(parsed.data.limit)
|
||||
return sendOk(reply, deliveries)
|
||||
})
|
||||
}
|
||||
285
apps/api/src/services/ai.service.ts
Normal file
285
apps/api/src/services/ai.service.ts
Normal file
@@ -0,0 +1,285 @@
|
||||
import { mkdir } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { createWorker, type Worker } from 'tesseract.js'
|
||||
import { AiRecognizeSubscriptionSchema } from '@subtracker/shared'
|
||||
import type { AiRecognitionResultDto } from '@subtracker/shared'
|
||||
import { getAppSettings } from './settings.service'
|
||||
|
||||
const defaultPrompt = `你是订阅账单信息提取助手。请从输入的文本或截图中提取订阅信息,并且只返回 JSON。
|
||||
输出字段:
|
||||
- name
|
||||
- description
|
||||
- amount
|
||||
- currency
|
||||
- billingIntervalCount
|
||||
- billingIntervalUnit(day|week|month|quarter|year)
|
||||
- startDate(YYYY-MM-DD)
|
||||
- nextRenewalDate(YYYY-MM-DD)
|
||||
- notifyDaysBefore
|
||||
- websiteUrl
|
||||
- notes
|
||||
- confidence(0~1)
|
||||
- rawText
|
||||
|
||||
规则:
|
||||
1. 不确定就留空,不要猜。
|
||||
2. 金额必须是数字。
|
||||
3. 货币必须是 3 位大写代码,例如 CNY、USD。
|
||||
4. 周期单位必须在 day/week/month/quarter/year 中。
|
||||
5. 只返回 JSON,不要返回 Markdown。`
|
||||
|
||||
export type AiSettings = Awaited<ReturnType<typeof getAppSettings>>['aiConfig']
|
||||
|
||||
const ocrCachePath = path.resolve(process.cwd(), 'apps/api/storage/tesseract-cache')
|
||||
let ocrWorkerPromise: Promise<Worker> | null = null
|
||||
|
||||
function ensureAiConfig(aiConfig: AiSettings) {
|
||||
if (!aiConfig.enabled) {
|
||||
throw new Error('AI 识别未启用')
|
||||
}
|
||||
|
||||
if (!aiConfig.baseUrl || !aiConfig.apiKey || !aiConfig.model) {
|
||||
throw new Error('AI 配置不完整')
|
||||
}
|
||||
}
|
||||
|
||||
function modelLooksVisionCapable(model: string) {
|
||||
const normalized = model.toLowerCase()
|
||||
return ['vision', 'vl', 'gpt-4o', 'gpt-4.1', 'gemini', 'claude-3', 'qwen-vl'].some((keyword) =>
|
||||
normalized.includes(keyword)
|
||||
)
|
||||
}
|
||||
|
||||
function looksLikeImageFormatUnsupported(errorText: string) {
|
||||
const normalized = errorText.toLowerCase()
|
||||
return (
|
||||
normalized.includes('unknown variant `image_url`') ||
|
||||
normalized.includes("unknown variant 'image_url'") ||
|
||||
normalized.includes('expected `text`') ||
|
||||
normalized.includes('expected "text"') ||
|
||||
normalized.includes('does not support image') ||
|
||||
normalized.includes('image input')
|
||||
)
|
||||
}
|
||||
|
||||
async function getOcrWorker() {
|
||||
if (!ocrWorkerPromise) {
|
||||
ocrWorkerPromise = (async () => {
|
||||
await mkdir(ocrCachePath, { recursive: true })
|
||||
return createWorker(['eng', 'chi_sim'], 1, {
|
||||
cachePath: ocrCachePath,
|
||||
logger: () => {}
|
||||
})
|
||||
})()
|
||||
}
|
||||
|
||||
return ocrWorkerPromise
|
||||
}
|
||||
|
||||
async function extractTextFromImageWithOcr(imageBase64: string) {
|
||||
const worker = await getOcrWorker()
|
||||
const imageBuffer = Buffer.from(imageBase64, 'base64')
|
||||
const result = await worker.recognize(imageBuffer)
|
||||
return (result.data.text || '').trim()
|
||||
}
|
||||
|
||||
async function requestAiChatCompletion(params: {
|
||||
aiConfig: AiSettings
|
||||
messages: Array<Record<string, unknown>>
|
||||
responseFormat?: { type: 'json_object' }
|
||||
}) {
|
||||
const { aiConfig } = params
|
||||
ensureAiConfig(aiConfig)
|
||||
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), aiConfig.timeoutMs)
|
||||
|
||||
try {
|
||||
const response = await fetch(`${aiConfig.baseUrl.replace(/\/$/, '')}/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${aiConfig.apiKey}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: aiConfig.model,
|
||||
temperature: 0.1,
|
||||
...(params.responseFormat ? { response_format: params.responseFormat } : {}),
|
||||
messages: params.messages
|
||||
}),
|
||||
signal: controller.signal
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
throw new Error(`AI 接口请求失败:${response.status}${errorText ? ` - ${errorText}` : ''}`)
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as {
|
||||
choices?: Array<{ message?: { content?: string } }>
|
||||
}
|
||||
|
||||
const raw = payload.choices?.[0]?.message?.content
|
||||
if (!raw) {
|
||||
throw new Error('AI 未返回有效内容')
|
||||
}
|
||||
|
||||
return raw
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
|
||||
function buildTextOnlyUserContent(input: { text?: string; ocrText?: string }) {
|
||||
const parts = [
|
||||
input.text?.trim() ? `原始文本:\n${input.text.trim()}` : '',
|
||||
input.ocrText?.trim() ? `OCR 提取文本:\n${input.ocrText.trim()}` : ''
|
||||
].filter(Boolean)
|
||||
|
||||
return parts.join('\n\n').trim()
|
||||
}
|
||||
|
||||
async function recognizeByTextOnly(params: {
|
||||
aiConfig: AiSettings
|
||||
text?: string
|
||||
ocrText?: string
|
||||
}) {
|
||||
const userText = buildTextOnlyUserContent({
|
||||
text: params.text,
|
||||
ocrText: params.ocrText
|
||||
})
|
||||
|
||||
if (!userText) {
|
||||
throw new Error('未获取到可用于识别的文本内容')
|
||||
}
|
||||
|
||||
const raw = await requestAiChatCompletion({
|
||||
aiConfig: params.aiConfig,
|
||||
responseFormat: { type: 'json_object' },
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: params.aiConfig.promptTemplate?.trim() || defaultPrompt
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: userText
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
return JSON.parse(raw) as AiRecognitionResultDto
|
||||
}
|
||||
|
||||
async function recognizeByVision(params: {
|
||||
aiConfig: AiSettings
|
||||
text?: string
|
||||
imageBase64: string
|
||||
mimeType?: string
|
||||
}) {
|
||||
const content: Array<Record<string, unknown>> = []
|
||||
|
||||
if (params.text?.trim()) {
|
||||
content.push({
|
||||
type: 'text',
|
||||
text: params.text.trim()
|
||||
})
|
||||
}
|
||||
|
||||
content.push({
|
||||
type: 'image_url',
|
||||
image_url: {
|
||||
url: `data:${params.mimeType || 'image/png'};base64,${params.imageBase64}`
|
||||
}
|
||||
})
|
||||
|
||||
const raw = await requestAiChatCompletion({
|
||||
aiConfig: params.aiConfig,
|
||||
responseFormat: { type: 'json_object' },
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: params.aiConfig.promptTemplate?.trim() || defaultPrompt
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
return JSON.parse(raw) as AiRecognitionResultDto
|
||||
}
|
||||
|
||||
export async function recognizeSubscriptionByAi(input: unknown): Promise<AiRecognitionResultDto> {
|
||||
const parsed = AiRecognizeSubscriptionSchema.safeParse(input)
|
||||
if (!parsed.success) {
|
||||
throw new Error('AI 识别输入不合法')
|
||||
}
|
||||
|
||||
const settings = await getAppSettings()
|
||||
const { aiConfig } = settings
|
||||
|
||||
const text = parsed.data.text?.trim()
|
||||
const imageBase64 = parsed.data.imageBase64
|
||||
|
||||
if (!imageBase64) {
|
||||
return recognizeByTextOnly({
|
||||
aiConfig,
|
||||
text
|
||||
})
|
||||
}
|
||||
|
||||
if (modelLooksVisionCapable(aiConfig.model)) {
|
||||
try {
|
||||
return await recognizeByVision({
|
||||
aiConfig,
|
||||
text,
|
||||
imageBase64,
|
||||
mimeType: parsed.data.mimeType
|
||||
})
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
if (!looksLikeImageFormatUnsupported(message)) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const ocrText = await extractTextFromImageWithOcr(imageBase64)
|
||||
if (!ocrText) {
|
||||
throw new Error('图片 OCR 未识别出有效文本,请改为手动输入文本内容')
|
||||
}
|
||||
|
||||
return recognizeByTextOnly({
|
||||
aiConfig,
|
||||
text,
|
||||
ocrText
|
||||
})
|
||||
}
|
||||
|
||||
export async function testAiConnection(overrideConfig?: AiSettings) {
|
||||
const settings = await getAppSettings()
|
||||
const aiConfig = overrideConfig ?? settings.aiConfig
|
||||
|
||||
const raw = await requestAiChatCompletion({
|
||||
aiConfig,
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: '请只返回 OK'
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: 'ping'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
providerName: aiConfig.providerName,
|
||||
model: aiConfig.model,
|
||||
response: raw.trim()
|
||||
}
|
||||
}
|
||||
165
apps/api/src/services/auth.service.ts
Normal file
165
apps/api/src/services/auth.service.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import { createHmac, randomBytes, scryptSync, timingSafeEqual } from 'node:crypto'
|
||||
import { getSetting, setSetting } from './settings.service'
|
||||
|
||||
const CREDENTIALS_KEY = 'authCredentials'
|
||||
const SESSION_SECRET_KEY = 'authSessionSecret'
|
||||
const DEFAULT_USERNAME = 'admin'
|
||||
const DEFAULT_PASSWORD = 'admin'
|
||||
const TOKEN_TTL_MS = 7 * 24 * 60 * 60 * 1000
|
||||
|
||||
type StoredCredentials = {
|
||||
username: string
|
||||
passwordHash: string
|
||||
passwordSalt: string
|
||||
}
|
||||
|
||||
type SessionPayload = {
|
||||
sub: string
|
||||
iat: number
|
||||
exp: number
|
||||
}
|
||||
|
||||
export type AuthUser = {
|
||||
username: string
|
||||
}
|
||||
|
||||
function hashPassword(password: string, salt: string) {
|
||||
return scryptSync(password, salt, 64).toString('hex')
|
||||
}
|
||||
|
||||
function createPasswordRecord(password: string) {
|
||||
const passwordSalt = randomBytes(16).toString('hex')
|
||||
const passwordHash = hashPassword(password, passwordSalt)
|
||||
return {
|
||||
passwordSalt,
|
||||
passwordHash
|
||||
}
|
||||
}
|
||||
|
||||
function encodeBase64Url(value: string) {
|
||||
return Buffer.from(value, 'utf8').toString('base64url')
|
||||
}
|
||||
|
||||
function decodeBase64Url(value: string) {
|
||||
return Buffer.from(value, 'base64url').toString('utf8')
|
||||
}
|
||||
|
||||
async function getSessionSecret() {
|
||||
const existing = await getSetting<string | null>(SESSION_SECRET_KEY, null)
|
||||
if (existing) return existing
|
||||
|
||||
const created = randomBytes(32).toString('hex')
|
||||
await setSetting(SESSION_SECRET_KEY, created)
|
||||
return created
|
||||
}
|
||||
|
||||
export async function getStoredCredentials() {
|
||||
const existing = await getSetting<StoredCredentials | null>(CREDENTIALS_KEY, null)
|
||||
if (existing) return existing
|
||||
|
||||
const defaultRecord = createPasswordRecord(DEFAULT_PASSWORD)
|
||||
const created: StoredCredentials = {
|
||||
username: DEFAULT_USERNAME,
|
||||
...defaultRecord
|
||||
}
|
||||
|
||||
await setSetting(CREDENTIALS_KEY, created)
|
||||
return created
|
||||
}
|
||||
|
||||
function verifyPassword(password: string, record: StoredCredentials) {
|
||||
const actual = Buffer.from(hashPassword(password, record.passwordSalt), 'hex')
|
||||
const expected = Buffer.from(record.passwordHash, 'hex')
|
||||
|
||||
if (actual.length !== expected.length) {
|
||||
return false
|
||||
}
|
||||
|
||||
return timingSafeEqual(actual, expected)
|
||||
}
|
||||
|
||||
async function signPayload(payload: SessionPayload) {
|
||||
const secret = await getSessionSecret()
|
||||
const body = encodeBase64Url(JSON.stringify(payload))
|
||||
const signature = createHmac('sha256', secret).update(body).digest('base64url')
|
||||
return `${body}.${signature}`
|
||||
}
|
||||
|
||||
export async function issueToken(username: string) {
|
||||
const now = Date.now()
|
||||
return signPayload({
|
||||
sub: username,
|
||||
iat: now,
|
||||
exp: now + TOKEN_TTL_MS
|
||||
})
|
||||
}
|
||||
|
||||
export async function verifyToken(token?: string) {
|
||||
if (!token) return null
|
||||
|
||||
const [body, signature] = token.split('.')
|
||||
if (!body || !signature) return null
|
||||
|
||||
const secret = await getSessionSecret()
|
||||
const expectedSignature = createHmac('sha256', secret).update(body).digest('base64url')
|
||||
|
||||
const actual = Buffer.from(signature)
|
||||
const expected = Buffer.from(expectedSignature)
|
||||
if (actual.length !== expected.length || !timingSafeEqual(actual, expected)) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(decodeBase64Url(body)) as SessionPayload
|
||||
if (!payload.sub || payload.exp < Date.now()) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
username: payload.sub
|
||||
} satisfies AuthUser
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function loginWithCredentials(username: string, password: string) {
|
||||
const credentials = await getStoredCredentials()
|
||||
if (credentials.username !== username || !verifyPassword(password, credentials)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
token: await issueToken(credentials.username),
|
||||
user: {
|
||||
username: credentials.username
|
||||
} satisfies AuthUser
|
||||
}
|
||||
}
|
||||
|
||||
export async function changeCredentials(input: {
|
||||
oldUsername: string
|
||||
oldPassword: string
|
||||
newUsername: string
|
||||
newPassword: string
|
||||
}) {
|
||||
const credentials = await getStoredCredentials()
|
||||
if (credentials.username !== input.oldUsername || !verifyPassword(input.oldPassword, credentials)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const nextPassword = createPasswordRecord(input.newPassword)
|
||||
const nextCredentials: StoredCredentials = {
|
||||
username: input.newUsername,
|
||||
...nextPassword
|
||||
}
|
||||
|
||||
await setSetting(CREDENTIALS_KEY, nextCredentials)
|
||||
|
||||
return {
|
||||
token: await issueToken(input.newUsername),
|
||||
user: {
|
||||
username: input.newUsername
|
||||
} satisfies AuthUser
|
||||
}
|
||||
}
|
||||
189
apps/api/src/services/channel-notification.service.ts
Normal file
189
apps/api/src/services/channel-notification.service.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import nodemailer from 'nodemailer'
|
||||
import type { EmailConfigInput, PushPlusConfigInput, WebhookEventType } from '@subtracker/shared'
|
||||
import { dispatchWebhookEvent } from './webhook.service'
|
||||
import { getAppSettings, getSetting, setSetting } from './settings.service'
|
||||
|
||||
type NotificationDispatchParams = {
|
||||
eventType: WebhookEventType
|
||||
resourceKey: string
|
||||
periodKey: string
|
||||
subscriptionId?: string
|
||||
payload: Record<string, unknown>
|
||||
}
|
||||
|
||||
function buildNotificationKey(channel: 'email' | 'pushplus', params: NotificationDispatchParams) {
|
||||
return `notification:${channel}:${params.eventType}:${params.resourceKey}:${params.periodKey}`
|
||||
}
|
||||
|
||||
async function hasNotificationBeenSent(channel: 'email' | 'pushplus', params: NotificationDispatchParams) {
|
||||
return getSetting<boolean>(buildNotificationKey(channel, params), false)
|
||||
}
|
||||
|
||||
async function markNotificationSent(channel: 'email' | 'pushplus', params: NotificationDispatchParams) {
|
||||
await setSetting(buildNotificationKey(channel, params), true)
|
||||
}
|
||||
|
||||
function buildNotificationTitle(params: NotificationDispatchParams) {
|
||||
switch (params.eventType) {
|
||||
case 'subscription.reminder_due':
|
||||
return `订阅即将续费:${String(params.payload.name ?? '')}`
|
||||
case 'subscription.overdue':
|
||||
return `订阅已过期:${String(params.payload.name ?? '')}`
|
||||
case 'subscription.renewed':
|
||||
return `订阅已续费:${String(params.payload.subscriptionId ?? params.payload.name ?? '')}`
|
||||
case 'exchange-rate.stale':
|
||||
return '汇率快照已过期'
|
||||
default:
|
||||
return 'SubTracker 通知'
|
||||
}
|
||||
}
|
||||
|
||||
function buildNotificationBody(params: NotificationDispatchParams) {
|
||||
return [
|
||||
`事件:${params.eventType}`,
|
||||
`资源:${params.resourceKey}`,
|
||||
`周期:${params.periodKey}`,
|
||||
'',
|
||||
JSON.stringify(params.payload, null, 2)
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
async function sendEmailWithConfig(params: NotificationDispatchParams, config: EmailConfigInput) {
|
||||
const { host, port, secure, username, password, from, to } = config
|
||||
if (!host || !port || !username || !password || !from || !to) {
|
||||
throw new Error('邮箱通知未启用或配置不完整')
|
||||
}
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
host,
|
||||
port,
|
||||
secure,
|
||||
auth: {
|
||||
user: username,
|
||||
pass: password
|
||||
}
|
||||
})
|
||||
|
||||
await transporter.sendMail({
|
||||
from,
|
||||
to,
|
||||
subject: buildNotificationTitle(params),
|
||||
text: buildNotificationBody(params)
|
||||
})
|
||||
}
|
||||
|
||||
async function sendEmailNotification(params: NotificationDispatchParams) {
|
||||
const settings = await getAppSettings()
|
||||
if (!settings.emailNotificationsEnabled) return false
|
||||
|
||||
const alreadySent = await hasNotificationBeenSent('email', params)
|
||||
if (alreadySent) return false
|
||||
|
||||
await sendEmailWithConfig(params, settings.emailConfig)
|
||||
await markNotificationSent('email', params)
|
||||
return true
|
||||
}
|
||||
|
||||
async function sendPushplusWithConfig(params: NotificationDispatchParams, config: PushPlusConfigInput) {
|
||||
const { token, topic } = config
|
||||
if (!token) {
|
||||
throw new Error('PushPlus 通知未启用或配置不完整')
|
||||
}
|
||||
|
||||
const response = await fetch('https://www.pushplus.plus/send', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
token,
|
||||
topic: topic || undefined,
|
||||
title: buildNotificationTitle(params),
|
||||
content: `<pre>${buildNotificationBody(params)}</pre>`,
|
||||
template: 'html'
|
||||
})
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`PushPlus request failed with status ${response.status}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function sendPushplusNotification(params: NotificationDispatchParams) {
|
||||
const settings = await getAppSettings()
|
||||
if (!settings.pushplusNotificationsEnabled) return false
|
||||
|
||||
const alreadySent = await hasNotificationBeenSent('pushplus', params)
|
||||
if (alreadySent) return false
|
||||
|
||||
await sendPushplusWithConfig(params, settings.pushplusConfig)
|
||||
await markNotificationSent('pushplus', params)
|
||||
return true
|
||||
}
|
||||
|
||||
export async function dispatchNotificationEvent(params: NotificationDispatchParams) {
|
||||
await dispatchWebhookEvent(params)
|
||||
await Promise.allSettled([sendEmailNotification(params), sendPushplusNotification(params)])
|
||||
}
|
||||
|
||||
export async function sendTestEmailNotification() {
|
||||
const success = await sendEmailNotification({
|
||||
eventType: 'subscription.reminder_due',
|
||||
resourceKey: 'test:email',
|
||||
periodKey: new Date().toISOString().slice(0, 10),
|
||||
payload: {
|
||||
name: '测试订阅',
|
||||
nextRenewalDate: new Date().toISOString()
|
||||
}
|
||||
})
|
||||
|
||||
if (!success) {
|
||||
throw new Error('邮箱通知未启用或配置不完整')
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendTestEmailNotificationWithConfig(config: EmailConfigInput) {
|
||||
await sendEmailWithConfig(
|
||||
{
|
||||
eventType: 'subscription.reminder_due',
|
||||
resourceKey: 'test:email',
|
||||
periodKey: new Date().toISOString().slice(0, 10),
|
||||
payload: {
|
||||
name: '测试订阅',
|
||||
nextRenewalDate: new Date().toISOString()
|
||||
}
|
||||
},
|
||||
config
|
||||
)
|
||||
}
|
||||
|
||||
export async function sendTestPushplusNotification() {
|
||||
const success = await sendPushplusNotification({
|
||||
eventType: 'subscription.reminder_due',
|
||||
resourceKey: 'test:pushplus',
|
||||
periodKey: new Date().toISOString().slice(0, 10),
|
||||
payload: {
|
||||
name: '测试订阅',
|
||||
nextRenewalDate: new Date().toISOString()
|
||||
}
|
||||
})
|
||||
|
||||
if (!success) {
|
||||
throw new Error('PushPlus 通知未启用或配置不完整')
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendTestPushplusNotificationWithConfig(config: PushPlusConfigInput) {
|
||||
await sendPushplusWithConfig(
|
||||
{
|
||||
eventType: 'subscription.reminder_due',
|
||||
resourceKey: 'test:pushplus',
|
||||
periodKey: new Date().toISOString().slice(0, 10),
|
||||
payload: {
|
||||
name: '测试订阅',
|
||||
nextRenewalDate: new Date().toISOString()
|
||||
}
|
||||
},
|
||||
config
|
||||
)
|
||||
}
|
||||
105
apps/api/src/services/exchange-rate.service.ts
Normal file
105
apps/api/src/services/exchange-rate.service.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import dayjs from 'dayjs'
|
||||
import { prisma } from '../db'
|
||||
import { config } from '../config'
|
||||
import { convertAmount } from '../utils/money'
|
||||
import { getSetting } from './settings.service'
|
||||
import type { ExchangeRateSnapshotDto } from '@subtracker/shared'
|
||||
|
||||
type ProviderResponse = {
|
||||
result?: string
|
||||
base_code?: string
|
||||
base?: string
|
||||
time_last_update_utc?: string
|
||||
rates: Record<string, number>
|
||||
}
|
||||
|
||||
export async function getBaseCurrency(): Promise<string> {
|
||||
const baseCurrency = await getSetting('baseCurrency', config.baseCurrency)
|
||||
return String(baseCurrency).toUpperCase()
|
||||
}
|
||||
|
||||
export async function getLatestSnapshot(baseCurrency?: string): Promise<ExchangeRateSnapshotDto> {
|
||||
const base = (baseCurrency ?? (await getBaseCurrency())).toUpperCase()
|
||||
let snapshot = await prisma.exchangeRateSnapshot.findUnique({ where: { baseCurrency: base } })
|
||||
|
||||
if (!snapshot) {
|
||||
snapshot = await refreshExchangeRates(base)
|
||||
}
|
||||
|
||||
return {
|
||||
baseCurrency: snapshot.baseCurrency,
|
||||
rates: snapshot.ratesJson as Record<string, number>,
|
||||
fetchedAt: snapshot.fetchedAt.toISOString(),
|
||||
provider: snapshot.provider,
|
||||
isStale: snapshot.isStale
|
||||
}
|
||||
}
|
||||
|
||||
export async function refreshExchangeRates(baseCurrency?: string) {
|
||||
const base = (baseCurrency ?? (await getBaseCurrency())).toUpperCase()
|
||||
|
||||
try {
|
||||
const response = await fetch(`${config.exchangeRateUrl}/${base}`)
|
||||
if (!response.ok) {
|
||||
throw new Error(`Rate provider failed with status ${response.status}`)
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as ProviderResponse
|
||||
const rates = payload.rates ?? {}
|
||||
|
||||
if (!Object.keys(rates).length) {
|
||||
throw new Error('Rate payload is empty')
|
||||
}
|
||||
|
||||
return await prisma.exchangeRateSnapshot.upsert({
|
||||
where: { baseCurrency: base },
|
||||
update: {
|
||||
ratesJson: rates,
|
||||
provider: config.exchangeRateProvider,
|
||||
fetchedAt: new Date(),
|
||||
isStale: false
|
||||
},
|
||||
create: {
|
||||
baseCurrency: base,
|
||||
ratesJson: rates,
|
||||
provider: config.exchangeRateProvider,
|
||||
fetchedAt: new Date(),
|
||||
isStale: false
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
const existing = await prisma.exchangeRateSnapshot.findUnique({ where: { baseCurrency: base } })
|
||||
if (existing) {
|
||||
return prisma.exchangeRateSnapshot.update({
|
||||
where: { baseCurrency: base },
|
||||
data: {
|
||||
isStale: true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureExchangeRates(baseCurrency?: string): Promise<ExchangeRateSnapshotDto> {
|
||||
const base = (baseCurrency ?? (await getBaseCurrency())).toUpperCase()
|
||||
const existing = await prisma.exchangeRateSnapshot.findUnique({ where: { baseCurrency: base } })
|
||||
|
||||
if (!existing) {
|
||||
return getLatestSnapshot(base)
|
||||
}
|
||||
|
||||
const shouldRefresh = dayjs().diff(dayjs(existing.fetchedAt), 'hour') >= 24
|
||||
|
||||
if (shouldRefresh) {
|
||||
await refreshExchangeRates(base)
|
||||
}
|
||||
|
||||
return getLatestSnapshot(base)
|
||||
}
|
||||
|
||||
export async function convertToBase(amount: number, sourceCurrency: string): Promise<number> {
|
||||
const snapshot = await ensureExchangeRates()
|
||||
return convertAmount(amount, sourceCurrency, snapshot.baseCurrency, snapshot.baseCurrency, snapshot.rates)
|
||||
}
|
||||
770
apps/api/src/services/logo.service.ts
Normal file
770
apps/api/src/services/logo.service.ts
Normal file
@@ -0,0 +1,770 @@
|
||||
import { mkdir, readdir, stat, unlink, writeFile } from 'node:fs/promises'
|
||||
import crypto from 'node:crypto'
|
||||
import path from 'node:path'
|
||||
import { prisma } from '../db'
|
||||
import type { LogoSearchResultDto } from '@subtracker/shared'
|
||||
|
||||
const logoDir = path.resolve(process.cwd(), 'apps/api/storage/logos')
|
||||
const SEARCH_USER_AGENT =
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36'
|
||||
const LOGO_REQUEST_TIMEOUT_MS = 20000
|
||||
const SEARCH_CACHE_TTL_MS = 5 * 60 * 1000
|
||||
const allowedTypes = new Set(['image/png', 'image/jpeg', 'image/jpg', 'image/webp', 'image/svg+xml'])
|
||||
const extensionMap: Record<string, string> = {
|
||||
'image/png': '.png',
|
||||
'image/jpeg': '.jpg',
|
||||
'image/jpg': '.jpg',
|
||||
'image/webp': '.webp',
|
||||
'image/svg+xml': '.svg'
|
||||
}
|
||||
|
||||
type RawCandidate = LogoSearchResultDto & {
|
||||
scoreHint?: number
|
||||
fallback?: boolean
|
||||
}
|
||||
|
||||
type ImageMeta = {
|
||||
finalUrl: string
|
||||
contentType: string
|
||||
width?: number
|
||||
height?: number
|
||||
buffer: Buffer
|
||||
}
|
||||
|
||||
type SearchCacheEntry = {
|
||||
expiresAt: number
|
||||
items: LogoSearchResultDto[]
|
||||
}
|
||||
|
||||
const searchCache = new Map<string, SearchCacheEntry>()
|
||||
|
||||
function normalizeWebsiteUrl(input?: string) {
|
||||
if (!input) return ''
|
||||
try {
|
||||
const withProtocol = /^https?:\/\//i.test(input) ? input : `https://${input}`
|
||||
const url = new URL(withProtocol)
|
||||
return url.origin
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
function deriveDomainSeed(name: string) {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '')
|
||||
.trim()
|
||||
}
|
||||
|
||||
function resolveUrl(origin: string, value?: string | null) {
|
||||
if (!value) return ''
|
||||
try {
|
||||
return new URL(value, `${origin}/`).toString()
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
function sourcePriority(source: string) {
|
||||
switch (source) {
|
||||
case 'manifest':
|
||||
case 'manifest:any':
|
||||
case 'manifest:maskable':
|
||||
case 'manifest:monochrome':
|
||||
return 100
|
||||
case 'apple-touch-icon':
|
||||
return 96
|
||||
case 'html-icon':
|
||||
return 90
|
||||
case 'mask-icon':
|
||||
return 84
|
||||
case 'og-image':
|
||||
return 72
|
||||
case 'duckduckgo':
|
||||
return 80
|
||||
case 'brave':
|
||||
return 68
|
||||
case 'favicon':
|
||||
return 38
|
||||
case 'google-favicon':
|
||||
return 28
|
||||
case 'clearbit':
|
||||
return 24
|
||||
case 'icon-horse':
|
||||
return 22
|
||||
default:
|
||||
return 50
|
||||
}
|
||||
}
|
||||
|
||||
function makeCandidate(
|
||||
list: RawCandidate[],
|
||||
label: string,
|
||||
logoUrl: string,
|
||||
source: string,
|
||||
options?: {
|
||||
websiteUrl?: string
|
||||
width?: number
|
||||
height?: number
|
||||
fallback?: boolean
|
||||
}
|
||||
) {
|
||||
if (!logoUrl) return
|
||||
|
||||
let normalizedUrl = ''
|
||||
try {
|
||||
normalizedUrl = new URL(logoUrl).toString()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
if (!/^https?:\/\//i.test(normalizedUrl)) return
|
||||
if (list.some((item) => item.logoUrl === normalizedUrl)) return
|
||||
|
||||
list.push({
|
||||
label,
|
||||
logoUrl: normalizedUrl,
|
||||
source,
|
||||
websiteUrl: options?.websiteUrl,
|
||||
width: options?.width,
|
||||
height: options?.height,
|
||||
fallback: options?.fallback ?? false,
|
||||
scoreHint: sourcePriority(source)
|
||||
})
|
||||
}
|
||||
|
||||
async function fetchText(url: string, headers?: Record<string, string>) {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'User-Agent': SEARCH_USER_AGENT,
|
||||
...headers
|
||||
},
|
||||
redirect: 'follow',
|
||||
signal: AbortSignal.timeout(LOGO_REQUEST_TIMEOUT_MS)
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Request failed: ${response.status}`)
|
||||
}
|
||||
|
||||
return response.text()
|
||||
}
|
||||
|
||||
async function fetchJson<T>(url: string, headers?: Record<string, string>) {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'User-Agent': SEARCH_USER_AGENT,
|
||||
...headers
|
||||
},
|
||||
redirect: 'follow',
|
||||
signal: AbortSignal.timeout(LOGO_REQUEST_TIMEOUT_MS)
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Request failed: ${response.status}`)
|
||||
}
|
||||
|
||||
return (await response.json()) as T
|
||||
}
|
||||
|
||||
function extractLinkMatches(html: string) {
|
||||
const matches: Array<{ rel: string; href: string }> = []
|
||||
const regex = /<link\b[^>]*rel=["']([^"']+)["'][^>]*href=["']([^"']+)["'][^>]*>/gi
|
||||
for (const match of html.matchAll(regex)) {
|
||||
matches.push({
|
||||
rel: (match[1] || '').toLowerCase(),
|
||||
href: match[2] || ''
|
||||
})
|
||||
}
|
||||
return matches
|
||||
}
|
||||
|
||||
function extractMetaImageMatches(html: string) {
|
||||
const matches: string[] = []
|
||||
const regex = /<meta\b[^>]*(?:property|name)=["'](?:og:image|twitter:image)["'][^>]*content=["']([^"']+)["'][^>]*>/gi
|
||||
for (const match of html.matchAll(regex)) {
|
||||
if (match[1]) matches.push(match[1])
|
||||
}
|
||||
return matches
|
||||
}
|
||||
|
||||
async function fetchManifestCandidates(manifestUrl: string, websiteUrl: string) {
|
||||
const candidates: RawCandidate[] = []
|
||||
|
||||
try {
|
||||
const manifest = await fetchJson<{ icons?: Array<{ src?: string; sizes?: string; purpose?: string }> }>(manifestUrl)
|
||||
for (const icon of manifest.icons ?? []) {
|
||||
const iconUrl = icon.src ? new URL(icon.src, manifestUrl).toString() : ''
|
||||
if (!iconUrl) continue
|
||||
|
||||
const [width, height] = parseSizes(icon.sizes)
|
||||
const sizeLabel = icon.sizes ? ` (${icon.sizes})` : ''
|
||||
makeCandidate(candidates, `Manifest 图标${sizeLabel}`, iconUrl, icon.purpose ? `manifest:${icon.purpose}` : 'manifest', {
|
||||
websiteUrl,
|
||||
width,
|
||||
height
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
return candidates
|
||||
}
|
||||
|
||||
return candidates
|
||||
}
|
||||
|
||||
function parseSizes(sizes?: string) {
|
||||
if (!sizes) return [undefined, undefined] as const
|
||||
const first = sizes.split(/\s+/).find((item) => /^\d+x\d+$/i.test(item))
|
||||
if (!first) return [undefined, undefined] as const
|
||||
const [width, height] = first.toLowerCase().split('x').map((item) => Number(item))
|
||||
return [Number.isFinite(width) ? width : undefined, Number.isFinite(height) ? height : undefined] as const
|
||||
}
|
||||
|
||||
async function fetchWebsiteCandidates(origin: string) {
|
||||
const candidates: RawCandidate[] = []
|
||||
|
||||
try {
|
||||
const html = await fetchText(origin)
|
||||
const links = extractLinkMatches(html)
|
||||
|
||||
for (const link of links) {
|
||||
const href = resolveUrl(origin, link.href)
|
||||
if (!href) continue
|
||||
|
||||
if (link.rel.includes('apple-touch-icon')) {
|
||||
makeCandidate(candidates, 'Apple Touch Icon', href, 'apple-touch-icon', { websiteUrl: origin })
|
||||
} else if (link.rel.includes('mask-icon')) {
|
||||
makeCandidate(candidates, 'Mask Icon', href, 'mask-icon', { websiteUrl: origin })
|
||||
} else if (link.rel.includes('icon')) {
|
||||
makeCandidate(candidates, '站点图标', href, 'html-icon', { websiteUrl: origin })
|
||||
} else if (link.rel.includes('manifest')) {
|
||||
const manifestCandidates = await fetchManifestCandidates(href, origin)
|
||||
manifestCandidates.forEach((item) => makeCandidate(candidates, item.label, item.logoUrl, item.source, item))
|
||||
}
|
||||
}
|
||||
|
||||
for (const image of extractMetaImageMatches(html)) {
|
||||
const imageUrl = resolveUrl(origin, image)
|
||||
if (imageUrl) {
|
||||
makeCandidate(candidates, '站点分享图', imageUrl, 'og-image', { websiteUrl: origin })
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return candidates
|
||||
}
|
||||
|
||||
return candidates
|
||||
}
|
||||
|
||||
async function getDuckDuckGoVqd(searchTerm: string) {
|
||||
try {
|
||||
const html = await fetchText(`https://duckduckgo.com/?q=${encodeURIComponent(searchTerm)}&ia=images`)
|
||||
const match = html.match(/vqd="?([\d-]+)"?/)
|
||||
return match?.[1] ?? ''
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchDuckDuckGoCandidates(searchTerm: string) {
|
||||
const candidates: RawCandidate[] = []
|
||||
const vqd = await getDuckDuckGoVqd(searchTerm)
|
||||
if (!vqd) return candidates
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
l: 'us-en',
|
||||
o: 'json',
|
||||
q: searchTerm,
|
||||
vqd,
|
||||
f: ',,transparent,Wide,',
|
||||
p: '1'
|
||||
})
|
||||
|
||||
const data = await fetchJson<{
|
||||
results?: Array<{ image?: string; thumbnail?: string; width?: number; height?: number }>
|
||||
}>(`https://duckduckgo.com/i.js?${params.toString()}`, {
|
||||
Accept: 'application/json',
|
||||
Referer: 'https://duckduckgo.com/'
|
||||
})
|
||||
|
||||
for (const [index, row] of (data.results ?? []).entries()) {
|
||||
const imageUrl = row.image || row.thumbnail || ''
|
||||
if (!imageUrl) continue
|
||||
|
||||
makeCandidate(candidates, `DuckDuckGo 候选 ${index + 1}`, imageUrl, 'duckduckgo', {
|
||||
width: row.width,
|
||||
height: row.height
|
||||
})
|
||||
if (candidates.length >= 30) break
|
||||
}
|
||||
} catch {
|
||||
return candidates
|
||||
}
|
||||
|
||||
return candidates
|
||||
}
|
||||
|
||||
async function fetchBraveCandidates(searchTerm: string) {
|
||||
const candidates: RawCandidate[] = []
|
||||
|
||||
try {
|
||||
const html = await fetchText(`https://search.brave.com/images?q=${encodeURIComponent(searchTerm)}`, {
|
||||
Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||
Referer: 'https://search.brave.com/'
|
||||
})
|
||||
|
||||
const imageRegex = /<img\b[^>]*(?:src|data-src)=["']([^"']+)["'][^>]*>/gi
|
||||
for (const [index, match] of Array.from(html.matchAll(imageRegex)).entries()) {
|
||||
const imageUrl = match[1] || ''
|
||||
if (!/^https?:\/\//i.test(imageUrl)) continue
|
||||
if (imageUrl.includes('cdn.search.brave.com')) continue
|
||||
if (/favicon|logo\.svg/i.test(imageUrl)) continue
|
||||
|
||||
makeCandidate(candidates, `Brave 候选 ${index + 1}`, imageUrl, 'brave')
|
||||
if (candidates.length >= 24) break
|
||||
}
|
||||
} catch {
|
||||
return candidates
|
||||
}
|
||||
|
||||
return candidates
|
||||
}
|
||||
|
||||
function inferContentTypeFromUrl(url: string) {
|
||||
try {
|
||||
const pathname = new URL(url).pathname.toLowerCase()
|
||||
if (pathname.endsWith('.png')) return 'image/png'
|
||||
if (pathname.endsWith('.jpg') || pathname.endsWith('.jpeg')) return 'image/jpeg'
|
||||
if (pathname.endsWith('.webp')) return 'image/webp'
|
||||
if (pathname.endsWith('.svg')) return 'image/svg+xml'
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function getImageDimensions(buffer: Buffer, contentType: string) {
|
||||
if (contentType === 'image/png') {
|
||||
if (buffer.length >= 24 && buffer.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) {
|
||||
return {
|
||||
width: buffer.readUInt32BE(16),
|
||||
height: buffer.readUInt32BE(20)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (contentType === 'image/jpeg' || contentType === 'image/jpg') {
|
||||
let offset = 2
|
||||
while (offset < buffer.length) {
|
||||
if (buffer[offset] !== 0xff) break
|
||||
const marker = buffer[offset + 1]
|
||||
const length = buffer.readUInt16BE(offset + 2)
|
||||
if (marker >= 0xc0 && marker <= 0xc3) {
|
||||
return {
|
||||
height: buffer.readUInt16BE(offset + 5),
|
||||
width: buffer.readUInt16BE(offset + 7)
|
||||
}
|
||||
}
|
||||
offset += 2 + length
|
||||
}
|
||||
}
|
||||
|
||||
if (contentType === 'image/webp' && buffer.length >= 30) {
|
||||
const chunk = buffer.toString('ascii', 12, 16)
|
||||
if (chunk === 'VP8X') {
|
||||
return {
|
||||
width: 1 + buffer.readUIntLE(24, 3),
|
||||
height: 1 + buffer.readUIntLE(27, 3)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (contentType === 'image/svg+xml') {
|
||||
const text = buffer.toString('utf8', 0, Math.min(buffer.length, 4096))
|
||||
const widthMatch = text.match(/\bwidth=["']?([\d.]+)(?:px)?["']?/i)
|
||||
const heightMatch = text.match(/\bheight=["']?([\d.]+)(?:px)?["']?/i)
|
||||
if (widthMatch && heightMatch) {
|
||||
return {
|
||||
width: Number(widthMatch[1]),
|
||||
height: Number(heightMatch[1])
|
||||
}
|
||||
}
|
||||
const viewBoxMatch = text.match(/\bviewBox=["'][^"']*?(\d+(?:\.\d+)?)\s+(\d+(?:\.\d+)?)["']/i)
|
||||
if (viewBoxMatch) {
|
||||
return {
|
||||
width: Number(viewBoxMatch[1]),
|
||||
height: Number(viewBoxMatch[2])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {}
|
||||
}
|
||||
|
||||
async function inspectRemoteImage(url: string): Promise<ImageMeta | null> {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'User-Agent': SEARCH_USER_AGENT,
|
||||
Accept: 'image/avif,image/webp,image/png,image/svg+xml,image/*;q=0.9,*/*;q=0.8'
|
||||
},
|
||||
redirect: 'follow',
|
||||
signal: AbortSignal.timeout(LOGO_REQUEST_TIMEOUT_MS)
|
||||
})
|
||||
|
||||
if (!response.ok) return null
|
||||
|
||||
const headerType = (response.headers.get('content-type') || '').split(';')[0].trim().toLowerCase()
|
||||
const contentType = headerType || inferContentTypeFromUrl(response.url || url)
|
||||
if (!allowedTypes.has(contentType)) return null
|
||||
|
||||
const arrayBuffer = await response.arrayBuffer()
|
||||
const buffer = Buffer.from(arrayBuffer)
|
||||
if (!buffer.length) return null
|
||||
|
||||
const { width, height } = getImageDimensions(buffer, contentType)
|
||||
|
||||
return {
|
||||
finalUrl: response.url || url,
|
||||
contentType,
|
||||
width,
|
||||
height,
|
||||
buffer
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function passesQualityFilter(meta: ImageMeta, source: string) {
|
||||
const { width, height } = meta
|
||||
|
||||
if (!width || !height) {
|
||||
return sourcePriority(source) >= 80
|
||||
}
|
||||
|
||||
if (width < 48 || height < 48) return false
|
||||
|
||||
const ratio = width / height
|
||||
if (ratio > 4 || ratio < 0.25) return false
|
||||
|
||||
const area = width * height
|
||||
if (area < 2304) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
function computeScore(candidate: RawCandidate, meta: ImageMeta) {
|
||||
let score = candidate.scoreHint ?? sourcePriority(candidate.source)
|
||||
const width = meta.width ?? candidate.width ?? 0
|
||||
const height = meta.height ?? candidate.height ?? 0
|
||||
|
||||
if (width && height) {
|
||||
score += Math.min(Math.min(width, height) / 8, 24)
|
||||
const ratio = width / height
|
||||
if (ratio >= 0.75 && ratio <= 1.5) score += 12
|
||||
else if (ratio >= 0.45 && ratio <= 2.2) score += 6
|
||||
else score -= 18
|
||||
} else {
|
||||
score -= 8
|
||||
}
|
||||
|
||||
if (candidate.fallback) score -= 15
|
||||
return score
|
||||
}
|
||||
|
||||
async function mapWithConcurrency<T, R>(items: T[], concurrency: number, worker: (item: T) => Promise<R>) {
|
||||
const results: R[] = new Array(items.length)
|
||||
let index = 0
|
||||
|
||||
const runners = Array.from({ length: Math.min(concurrency, items.length) }, async () => {
|
||||
while (index < items.length) {
|
||||
const current = index++
|
||||
results[current] = await worker(items[current])
|
||||
}
|
||||
})
|
||||
|
||||
await Promise.all(runners)
|
||||
return results
|
||||
}
|
||||
|
||||
function dedupeBy<T>(items: T[], getKey: (item: T) => string) {
|
||||
const seen = new Set<string>()
|
||||
const result: T[] = []
|
||||
for (const item of items) {
|
||||
const key = getKey(item)
|
||||
if (!key || seen.has(key)) continue
|
||||
seen.add(key)
|
||||
result.push(item)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function filenameFromLogoUrl(logoUrl: string) {
|
||||
return path.basename(new URL(logoUrl, 'http://localhost').pathname)
|
||||
}
|
||||
|
||||
export async function searchSubscriptionLogos(params: { name: string; websiteUrl?: string; categoryName?: string }) {
|
||||
const cacheKey = JSON.stringify({
|
||||
name: params.name.trim().toLowerCase(),
|
||||
websiteUrl: params.websiteUrl?.trim().toLowerCase() ?? '',
|
||||
categoryName: params.categoryName?.trim().toLowerCase() ?? ''
|
||||
})
|
||||
const cached = searchCache.get(cacheKey)
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
return cached.items
|
||||
}
|
||||
|
||||
const explicitOrigin = normalizeWebsiteUrl(params.websiteUrl)
|
||||
const fallbackSeed = deriveDomainSeed(params.name)
|
||||
const guessedOrigin = explicitOrigin || (fallbackSeed ? `https://${fallbackSeed}.com` : '')
|
||||
const searchTerm = [params.name, params.categoryName].filter(Boolean).join(' ').trim() || params.name
|
||||
|
||||
const rawCandidates: RawCandidate[] = []
|
||||
const [websiteCandidates, duckCandidates, braveCandidates] = await Promise.all([
|
||||
guessedOrigin ? fetchWebsiteCandidates(guessedOrigin) : Promise.resolve<RawCandidate[]>([]),
|
||||
fetchDuckDuckGoCandidates(`${searchTerm} logo`),
|
||||
fetchBraveCandidates(`${searchTerm} logo`)
|
||||
])
|
||||
|
||||
if (guessedOrigin) {
|
||||
const hostname = new URL(guessedOrigin).hostname
|
||||
websiteCandidates.forEach((item) => makeCandidate(rawCandidates, item.label, item.logoUrl, item.source, item))
|
||||
|
||||
makeCandidate(rawCandidates, '站点 favicon', `${guessedOrigin}/favicon.ico`, 'favicon', {
|
||||
websiteUrl: guessedOrigin,
|
||||
fallback: true
|
||||
})
|
||||
makeCandidate(
|
||||
rawCandidates,
|
||||
'Google Favicon',
|
||||
`https://www.google.com/s2/favicons?sz=256&domain_url=${encodeURIComponent(guessedOrigin)}`,
|
||||
'google-favicon',
|
||||
{
|
||||
websiteUrl: guessedOrigin,
|
||||
fallback: true,
|
||||
width: 256,
|
||||
height: 256
|
||||
}
|
||||
)
|
||||
makeCandidate(rawCandidates, 'Icon Horse', `https://icon.horse/icon/${hostname}`, 'icon-horse', {
|
||||
websiteUrl: guessedOrigin,
|
||||
fallback: true
|
||||
})
|
||||
makeCandidate(rawCandidates, 'Clearbit Logo', `https://logo.clearbit.com/${hostname}`, 'clearbit', {
|
||||
websiteUrl: guessedOrigin,
|
||||
fallback: true
|
||||
})
|
||||
}
|
||||
|
||||
duckCandidates.forEach((item) => makeCandidate(rawCandidates, item.label, item.logoUrl, item.source, item))
|
||||
braveCandidates.forEach((item) => makeCandidate(rawCandidates, item.label, item.logoUrl, item.source, item))
|
||||
|
||||
const prepared = rawCandidates
|
||||
.sort((a, b) => (b.scoreHint ?? 0) - (a.scoreHint ?? 0))
|
||||
.slice(0, 40)
|
||||
|
||||
const inspected = await mapWithConcurrency(prepared, 4, async (candidate) => {
|
||||
const meta = await inspectRemoteImage(candidate.logoUrl)
|
||||
if (!meta) return null
|
||||
if (!passesQualityFilter(meta, candidate.source)) return null
|
||||
|
||||
return {
|
||||
...candidate,
|
||||
logoUrl: meta.finalUrl,
|
||||
width: meta.width ?? candidate.width,
|
||||
height: meta.height ?? candidate.height,
|
||||
score: computeScore(candidate, meta)
|
||||
}
|
||||
})
|
||||
|
||||
const result = dedupeBy(
|
||||
inspected
|
||||
.filter((item): item is NonNullable<typeof item> => Boolean(item))
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, 24),
|
||||
(item) => item.logoUrl
|
||||
).map(({ score, scoreHint, fallback, ...item }) => item)
|
||||
|
||||
searchCache.set(cacheKey, {
|
||||
expiresAt: Date.now() + SEARCH_CACHE_TTL_MS,
|
||||
items: result
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
async function writeLogoBuffer(buffer: Buffer, contentType: string, logoSource: string) {
|
||||
await mkdir(logoDir, { recursive: true })
|
||||
|
||||
const filename = `${Date.now()}-${crypto.randomBytes(6).toString('hex')}${extensionMap[contentType] ?? '.png'}`
|
||||
const absolutePath = path.join(logoDir, filename)
|
||||
await writeFile(absolutePath, buffer)
|
||||
|
||||
return {
|
||||
logoUrl: `/static/logos/${filename}`,
|
||||
logoSource
|
||||
}
|
||||
}
|
||||
|
||||
function isLocalLogoUrl(url?: string | null) {
|
||||
return Boolean(url && url.startsWith('/static/logos/'))
|
||||
}
|
||||
|
||||
export async function saveUploadedLogo(input: { filename: string; contentType: string; base64: string }) {
|
||||
if (!allowedTypes.has(input.contentType)) {
|
||||
throw new Error('仅支持 PNG、JPG、WEBP、SVG 图片')
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(input.base64, 'base64')
|
||||
if (!buffer.length) {
|
||||
throw new Error('图片内容为空')
|
||||
}
|
||||
|
||||
return writeLogoBuffer(buffer, input.contentType, 'upload')
|
||||
}
|
||||
|
||||
export async function importRemoteLogo(input: { logoUrl: string; source?: string }) {
|
||||
const meta = await inspectRemoteImage(input.logoUrl)
|
||||
if (!meta) {
|
||||
throw new Error('远程 Logo 下载失败或图片不可用')
|
||||
}
|
||||
|
||||
return writeLogoBuffer(meta.buffer, meta.contentType, input.source || 'remote')
|
||||
}
|
||||
|
||||
export async function normalizeLogoForStorage(input: { logoUrl?: string | null; logoSource?: string | null }) {
|
||||
if (!input.logoUrl) {
|
||||
return {
|
||||
logoUrl: null,
|
||||
logoSource: input.logoSource ?? null,
|
||||
logoFetchedAt: null as Date | null
|
||||
}
|
||||
}
|
||||
|
||||
if (isLocalLogoUrl(input.logoUrl)) {
|
||||
return {
|
||||
logoUrl: input.logoUrl,
|
||||
logoSource: input.logoSource ?? 'upload',
|
||||
logoFetchedAt: new Date()
|
||||
}
|
||||
}
|
||||
|
||||
if (/^https?:\/\//i.test(input.logoUrl)) {
|
||||
const imported = await importRemoteLogo({
|
||||
logoUrl: input.logoUrl,
|
||||
source: input.logoSource ?? 'remote'
|
||||
})
|
||||
|
||||
return {
|
||||
logoUrl: imported.logoUrl,
|
||||
logoSource: imported.logoSource,
|
||||
logoFetchedAt: new Date()
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
logoUrl: input.logoUrl,
|
||||
logoSource: input.logoSource ?? null,
|
||||
logoFetchedAt: new Date()
|
||||
}
|
||||
}
|
||||
|
||||
export async function getLocalLogoLibrary() {
|
||||
await mkdir(logoDir, { recursive: true })
|
||||
|
||||
const localSubscriptions = await prisma.subscription.findMany({
|
||||
where: {
|
||||
logoUrl: {
|
||||
startsWith: '/static/logos/'
|
||||
}
|
||||
},
|
||||
select: {
|
||||
name: true,
|
||||
logoUrl: true,
|
||||
logoSource: true,
|
||||
updatedAt: true
|
||||
}
|
||||
})
|
||||
|
||||
const map = new Map<string, LogoSearchResultDto>()
|
||||
|
||||
for (const row of localSubscriptions) {
|
||||
if (!row.logoUrl) continue
|
||||
const existing = map.get(row.logoUrl)
|
||||
const filename = filenameFromLogoUrl(row.logoUrl)
|
||||
if (!existing) {
|
||||
map.set(row.logoUrl, {
|
||||
label: row.name,
|
||||
logoUrl: row.logoUrl,
|
||||
source: row.logoSource ?? 'local',
|
||||
isLocal: true,
|
||||
filename,
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
usageCount: 1,
|
||||
relatedSubscriptionNames: [row.name]
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
existing.usageCount = (existing.usageCount ?? 0) + 1
|
||||
existing.relatedSubscriptionNames = Array.from(new Set([...(existing.relatedSubscriptionNames ?? []), row.name])).slice(0, 3)
|
||||
if (!existing.updatedAt || existing.updatedAt < row.updatedAt.toISOString()) {
|
||||
existing.updatedAt = row.updatedAt.toISOString()
|
||||
existing.label = row.name
|
||||
}
|
||||
}
|
||||
|
||||
const files = await readdir(logoDir)
|
||||
for (const file of files) {
|
||||
const logoUrl = `/static/logos/${file}`
|
||||
if (map.has(logoUrl)) continue
|
||||
|
||||
const filePath = path.join(logoDir, file)
|
||||
const info = await stat(filePath)
|
||||
map.set(logoUrl, {
|
||||
label: '未使用 Logo',
|
||||
logoUrl,
|
||||
source: 'local-file',
|
||||
isLocal: true,
|
||||
filename: file,
|
||||
updatedAt: info.mtime.toISOString(),
|
||||
usageCount: 0,
|
||||
relatedSubscriptionNames: []
|
||||
})
|
||||
}
|
||||
|
||||
return Array.from(map.values()).sort((a, b) => {
|
||||
const updatedDiff = new Date(b.updatedAt ?? 0).valueOf() - new Date(a.updatedAt ?? 0).valueOf()
|
||||
if (updatedDiff !== 0) return updatedDiff
|
||||
return (b.usageCount ?? 0) - (a.usageCount ?? 0)
|
||||
})
|
||||
}
|
||||
|
||||
export async function deleteLocalLogoFromLibrary(filename: string) {
|
||||
const safeName = path.basename(filename)
|
||||
if (!safeName) {
|
||||
throw new Error('无效的 Logo 文件名')
|
||||
}
|
||||
|
||||
const logoUrl = `/static/logos/${safeName}`
|
||||
const usageCount = await prisma.subscription.count({
|
||||
where: {
|
||||
logoUrl
|
||||
}
|
||||
})
|
||||
|
||||
if (usageCount > 0) {
|
||||
throw new Error('该 Logo 已被订阅使用,不能删除')
|
||||
}
|
||||
|
||||
const filePath = path.join(logoDir, safeName)
|
||||
await unlink(filePath)
|
||||
|
||||
return {
|
||||
filename: safeName,
|
||||
logoUrl,
|
||||
deleted: true
|
||||
}
|
||||
}
|
||||
83
apps/api/src/services/notification.service.ts
Normal file
83
apps/api/src/services/notification.service.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import dayjs from 'dayjs'
|
||||
import { prisma } from '../db'
|
||||
import { isOverdue, isReminderDue, toIsoDate } from '../utils/date'
|
||||
import { getLatestSnapshot } from './exchange-rate.service'
|
||||
import { dispatchNotificationEvent } from './channel-notification.service'
|
||||
|
||||
export async function scanRenewalNotifications() {
|
||||
const subscriptions = await prisma.subscription.findMany({
|
||||
where: {
|
||||
status: { in: ['active', 'expired'] },
|
||||
webhookEnabled: true
|
||||
},
|
||||
include: {
|
||||
category: true
|
||||
}
|
||||
})
|
||||
|
||||
const today = dayjs().startOf('day').toDate()
|
||||
|
||||
for (const sub of subscriptions) {
|
||||
const periodKey = toIsoDate(sub.nextRenewalDate)
|
||||
const resourceKey = `subscription:${sub.id}`
|
||||
|
||||
if (isOverdue(today, sub.nextRenewalDate)) {
|
||||
await dispatchNotificationEvent({
|
||||
eventType: 'subscription.overdue',
|
||||
resourceKey,
|
||||
periodKey,
|
||||
subscriptionId: sub.id,
|
||||
payload: {
|
||||
id: sub.id,
|
||||
name: sub.name,
|
||||
nextRenewalDate: sub.nextRenewalDate.toISOString(),
|
||||
amount: sub.amount,
|
||||
currency: sub.currency,
|
||||
status: sub.status
|
||||
}
|
||||
})
|
||||
|
||||
await prisma.subscription.update({
|
||||
where: { id: sub.id },
|
||||
data: { status: 'expired' }
|
||||
})
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (isReminderDue(today, sub.nextRenewalDate, sub.notifyDaysBefore)) {
|
||||
await dispatchNotificationEvent({
|
||||
eventType: 'subscription.reminder_due',
|
||||
resourceKey,
|
||||
periodKey,
|
||||
subscriptionId: sub.id,
|
||||
payload: {
|
||||
id: sub.id,
|
||||
name: sub.name,
|
||||
nextRenewalDate: sub.nextRenewalDate.toISOString(),
|
||||
notifyDaysBefore: sub.notifyDaysBefore,
|
||||
amount: sub.amount,
|
||||
currency: sub.currency,
|
||||
category: sub.category?.name ?? null
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function notifyIfExchangeRateStale() {
|
||||
const snapshot = await getLatestSnapshot()
|
||||
if (!snapshot.isStale) return
|
||||
|
||||
await dispatchNotificationEvent({
|
||||
eventType: 'exchange-rate.stale',
|
||||
resourceKey: `exchange-rate:${snapshot.baseCurrency}`,
|
||||
periodKey: dayjs().format('YYYY-MM-DD'),
|
||||
payload: {
|
||||
baseCurrency: snapshot.baseCurrency,
|
||||
fetchedAt: snapshot.fetchedAt,
|
||||
provider: snapshot.provider,
|
||||
isStale: snapshot.isStale
|
||||
}
|
||||
})
|
||||
}
|
||||
25
apps/api/src/services/scheduler.service.ts
Normal file
25
apps/api/src/services/scheduler.service.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import cron from 'node-cron'
|
||||
import { config } from '../config'
|
||||
import { refreshExchangeRates } from './exchange-rate.service'
|
||||
import { notifyIfExchangeRateStale, scanRenewalNotifications } from './notification.service'
|
||||
|
||||
export function startSchedulers() {
|
||||
cron.schedule(config.cronRefreshRates, async () => {
|
||||
try {
|
||||
await refreshExchangeRates()
|
||||
await notifyIfExchangeRateStale()
|
||||
console.log('[cron] exchange rates refreshed')
|
||||
} catch (e) {
|
||||
console.error('[cron] exchange rate refresh failed', e)
|
||||
}
|
||||
})
|
||||
|
||||
cron.schedule(config.cronScan, async () => {
|
||||
try {
|
||||
await scanRenewalNotifications()
|
||||
console.log('[cron] subscription reminders scanned')
|
||||
} catch (e) {
|
||||
console.error('[cron] reminder scan failed', e)
|
||||
}
|
||||
})
|
||||
}
|
||||
64
apps/api/src/services/settings.service.ts
Normal file
64
apps/api/src/services/settings.service.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import type { SettingsInput } from '@subtracker/shared'
|
||||
import { prisma } from '../db'
|
||||
import { config } from '../config'
|
||||
|
||||
export async function getSetting<T>(key: string, fallback: T): Promise<T> {
|
||||
const row = await prisma.setting.findUnique({ where: { key } })
|
||||
if (!row) return fallback
|
||||
return row.valueJson as T
|
||||
}
|
||||
|
||||
export async function setSetting<T>(key: string, value: T): Promise<void> {
|
||||
await prisma.setting.upsert({
|
||||
where: { key },
|
||||
update: { valueJson: value as object },
|
||||
create: { key, valueJson: value as object }
|
||||
})
|
||||
}
|
||||
|
||||
export async function getAppSettings(): Promise<SettingsInput> {
|
||||
const baseCurrency = await getSetting('baseCurrency', config.baseCurrency)
|
||||
const defaultNotifyDays = await getSetting('defaultNotifyDays', config.defaultNotifyDays)
|
||||
const monthlyBudgetBase = await getSetting<number | null>('monthlyBudgetBase', null)
|
||||
const yearlyBudgetBase = await getSetting<number | null>('yearlyBudgetBase', null)
|
||||
const enableCategoryBudgets = await getSetting('enableCategoryBudgets', false)
|
||||
const categoryBudgets = await getSetting<Record<string, number>>('categoryBudgets', {})
|
||||
const emailNotificationsEnabled = await getSetting('emailNotificationsEnabled', false)
|
||||
const pushplusNotificationsEnabled = await getSetting('pushplusNotificationsEnabled', false)
|
||||
const emailConfig = await getSetting<SettingsInput['emailConfig']>('emailConfig', {
|
||||
host: '',
|
||||
port: 587,
|
||||
secure: false,
|
||||
username: '',
|
||||
password: '',
|
||||
from: '',
|
||||
to: ''
|
||||
})
|
||||
const pushplusConfig = await getSetting<SettingsInput['pushplusConfig']>('pushplusConfig', {
|
||||
token: '',
|
||||
topic: ''
|
||||
})
|
||||
const aiConfig = await getSetting<SettingsInput['aiConfig']>('aiConfig', {
|
||||
enabled: false,
|
||||
providerName: 'DeepSeek',
|
||||
baseUrl: 'https://api.deepseek.com',
|
||||
apiKey: '',
|
||||
model: 'deepseek-chat',
|
||||
timeoutMs: 30000,
|
||||
promptTemplate: ''
|
||||
})
|
||||
|
||||
return {
|
||||
baseCurrency,
|
||||
defaultNotifyDays,
|
||||
monthlyBudgetBase,
|
||||
yearlyBudgetBase,
|
||||
enableCategoryBudgets,
|
||||
categoryBudgets,
|
||||
emailNotificationsEnabled,
|
||||
pushplusNotificationsEnabled,
|
||||
emailConfig,
|
||||
pushplusConfig,
|
||||
aiConfig
|
||||
}
|
||||
}
|
||||
158
apps/api/src/services/statistics.service.ts
Normal file
158
apps/api/src/services/statistics.service.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import dayjs from 'dayjs'
|
||||
import { prisma } from '../db'
|
||||
import { ensureExchangeRates, getBaseCurrency } from './exchange-rate.service'
|
||||
import { convertAmount } from '../utils/money'
|
||||
import { monthKey } from '../utils/date'
|
||||
import { getAppSettings } from './settings.service'
|
||||
|
||||
function monthlyFactor(unit: string, count: number) {
|
||||
switch (unit) {
|
||||
case 'day':
|
||||
return 30 / count
|
||||
case 'week':
|
||||
return 4.345 / count
|
||||
case 'month':
|
||||
return 1 / count
|
||||
case 'quarter':
|
||||
return 1 / (count * 3)
|
||||
case 'year':
|
||||
return 1 / (count * 12)
|
||||
default:
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
export async function getOverviewStatistics() {
|
||||
const [subscriptions, paymentRecords, appSettings] = await Promise.all([
|
||||
prisma.subscription.findMany({
|
||||
include: { category: true },
|
||||
where: { status: { in: ['active', 'paused', 'expired'] } }
|
||||
}),
|
||||
prisma.paymentRecord.findMany({
|
||||
orderBy: { paidAt: 'asc' }
|
||||
}),
|
||||
getAppSettings()
|
||||
])
|
||||
|
||||
const today = dayjs()
|
||||
const next7 = today.add(7, 'day')
|
||||
const next30 = today.add(30, 'day')
|
||||
|
||||
const rates = await ensureExchangeRates()
|
||||
const baseCurrency = await getBaseCurrency()
|
||||
|
||||
let monthlyEstimatedBase = 0
|
||||
let yearlyEstimatedBase = 0
|
||||
|
||||
const categoryMap = new Map<string, number>()
|
||||
const categoryBudgetMap = new Map<string, { name: string; spent: number }>()
|
||||
|
||||
for (const subscription of subscriptions.filter((item) => item.status === 'active')) {
|
||||
const baseAmount = convertAmount(
|
||||
subscription.amount,
|
||||
subscription.currency,
|
||||
baseCurrency,
|
||||
rates.baseCurrency,
|
||||
rates.rates
|
||||
)
|
||||
const monthly = baseAmount * monthlyFactor(subscription.billingIntervalUnit, subscription.billingIntervalCount)
|
||||
monthlyEstimatedBase += monthly
|
||||
yearlyEstimatedBase += monthly * 12
|
||||
|
||||
const categoryName = subscription.category?.name ?? '未分类'
|
||||
categoryMap.set(categoryName, (categoryMap.get(categoryName) ?? 0) + monthly)
|
||||
|
||||
if (subscription.categoryId && subscription.category) {
|
||||
const current = categoryBudgetMap.get(subscription.categoryId) ?? {
|
||||
name: subscription.category.name,
|
||||
spent: 0
|
||||
}
|
||||
current.spent += monthly
|
||||
categoryBudgetMap.set(subscription.categoryId, current)
|
||||
}
|
||||
}
|
||||
|
||||
const monthlyTrendMap = new Map<string, number>()
|
||||
for (const payment of paymentRecords) {
|
||||
const key = monthKey(payment.paidAt)
|
||||
monthlyTrendMap.set(key, (monthlyTrendMap.get(key) ?? 0) + payment.convertedAmount)
|
||||
}
|
||||
|
||||
const currencyDistributionMap = new Map<string, number>()
|
||||
for (const subscription of subscriptions.filter((item) => item.status === 'active')) {
|
||||
currencyDistributionMap.set(
|
||||
subscription.currency,
|
||||
(currencyDistributionMap.get(subscription.currency) ?? 0) + subscription.amount
|
||||
)
|
||||
}
|
||||
|
||||
const upcoming = subscriptions.filter(
|
||||
(subscription) =>
|
||||
dayjs(subscription.nextRenewalDate).isAfter(today) && dayjs(subscription.nextRenewalDate).isBefore(next30)
|
||||
)
|
||||
|
||||
return {
|
||||
activeSubscriptions: subscriptions.filter((item) => item.status === 'active').length,
|
||||
upcoming7Days: subscriptions.filter(
|
||||
(item) => dayjs(item.nextRenewalDate).isAfter(today) && dayjs(item.nextRenewalDate).isBefore(next7)
|
||||
).length,
|
||||
upcoming30Days: upcoming.length,
|
||||
monthlyEstimatedBase: Number(monthlyEstimatedBase.toFixed(2)),
|
||||
yearlyEstimatedBase: Number(yearlyEstimatedBase.toFixed(2)),
|
||||
monthlyBudgetBase: appSettings.monthlyBudgetBase,
|
||||
yearlyBudgetBase: appSettings.yearlyBudgetBase,
|
||||
monthlyBudgetUsageRatio:
|
||||
appSettings.monthlyBudgetBase && appSettings.monthlyBudgetBase > 0
|
||||
? Number((monthlyEstimatedBase / appSettings.monthlyBudgetBase).toFixed(4))
|
||||
: null,
|
||||
yearlyBudgetUsageRatio:
|
||||
appSettings.yearlyBudgetBase && appSettings.yearlyBudgetBase > 0
|
||||
? Number((yearlyEstimatedBase / appSettings.yearlyBudgetBase).toFixed(4))
|
||||
: null,
|
||||
categorySpend: Array.from(categoryMap.entries()).map(([name, value]) => ({
|
||||
name,
|
||||
value: Number(value.toFixed(2))
|
||||
})),
|
||||
monthlyTrend: Array.from(monthlyTrendMap.entries()).map(([month, amount]) => ({
|
||||
month,
|
||||
amount: Number(amount.toFixed(2))
|
||||
})),
|
||||
currencyDistribution: Array.from(currencyDistributionMap.entries()).map(([currency, amount]) => ({
|
||||
currency,
|
||||
amount: Number(amount.toFixed(2))
|
||||
})),
|
||||
categoryBudgetUsage: appSettings.enableCategoryBudgets
|
||||
? Array.from(categoryBudgetMap.entries()).flatMap(([categoryId, item]) => {
|
||||
const budget = appSettings.categoryBudgets[categoryId]
|
||||
if (budget === undefined) return []
|
||||
|
||||
return [
|
||||
{
|
||||
categoryId,
|
||||
name: item.name,
|
||||
budget: Number(budget.toFixed(2)),
|
||||
spent: Number(item.spent.toFixed(2)),
|
||||
ratio: budget > 0 ? Number((item.spent / budget).toFixed(4)) : 0
|
||||
}
|
||||
]
|
||||
})
|
||||
: [],
|
||||
upcomingRenewals: upcoming
|
||||
.sort((a, b) => a.nextRenewalDate.getTime() - b.nextRenewalDate.getTime())
|
||||
.map((item) => ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
nextRenewalDate: item.nextRenewalDate.toISOString(),
|
||||
amount: item.amount,
|
||||
currency: item.currency,
|
||||
convertedAmount: convertAmount(
|
||||
item.amount,
|
||||
item.currency,
|
||||
baseCurrency,
|
||||
rates.baseCurrency,
|
||||
rates.rates
|
||||
),
|
||||
status: item.status
|
||||
}))
|
||||
}
|
||||
}
|
||||
43
apps/api/src/services/subscription-order.service.ts
Normal file
43
apps/api/src/services/subscription-order.service.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { getSetting, setSetting } from './settings.service'
|
||||
|
||||
const SUBSCRIPTION_ORDER_KEY = 'subscriptionOrder'
|
||||
|
||||
function uniqueIds(ids: string[]) {
|
||||
return Array.from(new Set(ids.filter(Boolean)))
|
||||
}
|
||||
|
||||
export async function getSubscriptionOrder() {
|
||||
return uniqueIds(await getSetting<string[]>(SUBSCRIPTION_ORDER_KEY, []))
|
||||
}
|
||||
|
||||
export async function setSubscriptionOrder(ids: string[]) {
|
||||
await setSetting(SUBSCRIPTION_ORDER_KEY, uniqueIds(ids))
|
||||
}
|
||||
|
||||
export async function appendSubscriptionOrder(id: string) {
|
||||
const current = await getSubscriptionOrder()
|
||||
if (!current.includes(id)) {
|
||||
current.push(id)
|
||||
await setSubscriptionOrder(current)
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeSubscriptionOrder(id: string) {
|
||||
const current = await getSubscriptionOrder()
|
||||
await setSubscriptionOrder(current.filter((item) => item !== id))
|
||||
}
|
||||
|
||||
export async function sortSubscriptionsByOrder<T extends { id: string }>(rows: T[]) {
|
||||
const order = await getSubscriptionOrder()
|
||||
const orderIndex = new Map(order.map((id, index) => [id, index]))
|
||||
|
||||
return [...rows].sort((a, b) => {
|
||||
const aIndex = orderIndex.get(a.id)
|
||||
const bIndex = orderIndex.get(b.id)
|
||||
|
||||
if (aIndex === undefined && bIndex === undefined) return 0
|
||||
if (aIndex === undefined) return 1
|
||||
if (bIndex === undefined) return -1
|
||||
return aIndex - bIndex
|
||||
})
|
||||
}
|
||||
54
apps/api/src/services/subscription.service.ts
Normal file
54
apps/api/src/services/subscription.service.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { prisma } from '../db'
|
||||
import { addInterval } from '../utils/date'
|
||||
import { ensureExchangeRates, getBaseCurrency } from './exchange-rate.service'
|
||||
import { convertAmount } from '../utils/money'
|
||||
|
||||
export async function renewSubscription(subscriptionId: string, paidAt?: Date, paidAmount?: number, paidCurrency?: string) {
|
||||
const subscription = await prisma.subscription.findUnique({ where: { id: subscriptionId } })
|
||||
if (!subscription) {
|
||||
throw new Error('Subscription not found')
|
||||
}
|
||||
|
||||
const amount = paidAmount ?? subscription.amount
|
||||
const currency = (paidCurrency ?? subscription.currency).toUpperCase()
|
||||
const baseCurrency = await getBaseCurrency()
|
||||
const rates = await ensureExchangeRates(baseCurrency)
|
||||
const convertedAmount = convertAmount(amount, currency, baseCurrency, rates.baseCurrency, rates.rates)
|
||||
const exchangeRate = Number((convertedAmount / amount).toFixed(8))
|
||||
|
||||
const periodStart = subscription.nextRenewalDate
|
||||
const periodEnd = addInterval(
|
||||
subscription.nextRenewalDate,
|
||||
subscription.billingIntervalCount,
|
||||
subscription.billingIntervalUnit
|
||||
)
|
||||
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const payment = await tx.paymentRecord.create({
|
||||
data: {
|
||||
subscriptionId: subscription.id,
|
||||
amount,
|
||||
currency,
|
||||
baseCurrency,
|
||||
convertedAmount,
|
||||
exchangeRate,
|
||||
paidAt: paidAt ?? new Date(),
|
||||
periodStart,
|
||||
periodEnd
|
||||
}
|
||||
})
|
||||
|
||||
const updated = await tx.subscription.update({
|
||||
where: { id: subscription.id },
|
||||
data: {
|
||||
nextRenewalDate: periodEnd,
|
||||
status: 'active'
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
payment,
|
||||
subscription: updated
|
||||
}
|
||||
})
|
||||
}
|
||||
207
apps/api/src/services/webhook.service.ts
Normal file
207
apps/api/src/services/webhook.service.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
import { Prisma } from '@prisma/client'
|
||||
import { prisma } from '../db'
|
||||
import { signWebhookPayload } from '../utils/webhook'
|
||||
import type { WebhookEventType } from '@subtracker/shared'
|
||||
|
||||
type DeliveryPayload = Record<string, unknown>
|
||||
export type PrimaryWebhookInput = {
|
||||
url: string
|
||||
secret: string
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
const ALL_WEBHOOK_EVENTS: WebhookEventType[] = [
|
||||
'subscription.reminder_due',
|
||||
'subscription.overdue',
|
||||
'subscription.renewed',
|
||||
'exchange-rate.stale'
|
||||
]
|
||||
|
||||
const PRIMARY_WEBHOOK_NAME = 'Default Webhook'
|
||||
|
||||
export async function listWebhookEndpoints() {
|
||||
return prisma.webhookEndpoint.findMany({ orderBy: { createdAt: 'desc' } })
|
||||
}
|
||||
|
||||
export async function getPrimaryWebhookEndpoint() {
|
||||
return prisma.webhookEndpoint.findFirst({
|
||||
orderBy: { createdAt: 'asc' }
|
||||
})
|
||||
}
|
||||
|
||||
export async function listWebhookDeliveries(limit = 100) {
|
||||
return prisma.webhookDelivery.findMany({
|
||||
take: limit,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
endpoint: {
|
||||
select: { id: true, name: true, url: true }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function postWebhook(
|
||||
endpoint: { url: string; secret: string },
|
||||
payload: {
|
||||
eventType: WebhookEventType | 'test'
|
||||
payload: DeliveryPayload
|
||||
}
|
||||
) {
|
||||
const body = JSON.stringify({
|
||||
eventType: payload.eventType,
|
||||
timestamp: new Date().toISOString(),
|
||||
payload: payload.payload
|
||||
})
|
||||
|
||||
const signature = signWebhookPayload(endpoint.secret, body)
|
||||
|
||||
const response = await fetch(endpoint.url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Subtracker-Signature': signature
|
||||
},
|
||||
body
|
||||
})
|
||||
|
||||
return {
|
||||
response,
|
||||
responseBody: await response.text()
|
||||
}
|
||||
}
|
||||
|
||||
export async function upsertPrimaryWebhookEndpoint(input: PrimaryWebhookInput) {
|
||||
const current = await getPrimaryWebhookEndpoint()
|
||||
const data = {
|
||||
name: PRIMARY_WEBHOOK_NAME,
|
||||
url: input.url.trim(),
|
||||
secret: input.secret.trim(),
|
||||
enabled: input.enabled,
|
||||
eventsJson: ALL_WEBHOOK_EVENTS
|
||||
}
|
||||
|
||||
if (current) {
|
||||
return prisma.webhookEndpoint.update({
|
||||
where: { id: current.id },
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
return prisma.webhookEndpoint.create({
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export async function sendTestWebhookNotification() {
|
||||
const endpoint = await getPrimaryWebhookEndpoint()
|
||||
if (!endpoint || !endpoint.enabled || !endpoint.url || !endpoint.secret) {
|
||||
throw new Error('Webhook 未启用或配置不完整')
|
||||
}
|
||||
|
||||
return sendTestWebhookNotificationWithConfig({
|
||||
url: endpoint.url,
|
||||
secret: endpoint.secret,
|
||||
enabled: endpoint.enabled
|
||||
})
|
||||
}
|
||||
|
||||
export async function sendTestWebhookNotificationWithConfig(input: PrimaryWebhookInput) {
|
||||
if (!input.enabled || !input.url || !input.secret) {
|
||||
throw new Error('Webhook 未启用或配置不完整')
|
||||
}
|
||||
|
||||
const { response, responseBody } = await postWebhook(
|
||||
{
|
||||
url: input.url.trim(),
|
||||
secret: input.secret.trim()
|
||||
},
|
||||
{
|
||||
eventType: 'test',
|
||||
payload: {
|
||||
source: 'SubTracker',
|
||||
message: '这是一条测试通知',
|
||||
sentAt: new Date().toISOString()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Webhook 测试失败:HTTP ${response.status} ${responseBody || ''}`.trim())
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export async function dispatchWebhookEvent(params: {
|
||||
eventType: WebhookEventType
|
||||
resourceKey: string
|
||||
periodKey: string
|
||||
payload: DeliveryPayload
|
||||
subscriptionId?: string
|
||||
}) {
|
||||
const endpoints = await prisma.webhookEndpoint.findMany({ where: { enabled: true } })
|
||||
|
||||
for (const endpoint of endpoints) {
|
||||
const events = endpoint.eventsJson as string[]
|
||||
if (!events.includes(params.eventType)) continue
|
||||
|
||||
const existing = await prisma.webhookDelivery.findUnique({
|
||||
where: {
|
||||
endpointId_eventType_resourceKey_periodKey: {
|
||||
endpointId: endpoint.id,
|
||||
eventType: params.eventType,
|
||||
resourceKey: params.resourceKey,
|
||||
periodKey: params.periodKey
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (existing?.status === 'success') {
|
||||
continue
|
||||
}
|
||||
|
||||
const target =
|
||||
existing ??
|
||||
(await prisma.webhookDelivery.create({
|
||||
data: {
|
||||
endpointId: endpoint.id,
|
||||
eventType: params.eventType,
|
||||
resourceKey: params.resourceKey,
|
||||
periodKey: params.periodKey,
|
||||
subscriptionId: params.subscriptionId,
|
||||
payloadJson: params.payload as Prisma.InputJsonValue,
|
||||
status: 'pending'
|
||||
}
|
||||
}))
|
||||
|
||||
try {
|
||||
const { response, responseBody } = await postWebhook(endpoint, {
|
||||
eventType: params.eventType,
|
||||
payload: params.payload
|
||||
})
|
||||
|
||||
await prisma.webhookDelivery.update({
|
||||
where: { id: target.id },
|
||||
data: {
|
||||
status: response.ok ? 'success' : 'failed',
|
||||
responseCode: response.status,
|
||||
responseBody,
|
||||
attemptCount: { increment: 1 },
|
||||
lastAttemptAt: new Date()
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
await prisma.webhookDelivery.update({
|
||||
where: { id: target.id },
|
||||
data: {
|
||||
status: 'failed',
|
||||
responseCode: 0,
|
||||
responseBody: error instanceof Error ? error.message : 'Unknown error',
|
||||
attemptCount: { increment: 1 },
|
||||
lastAttemptAt: new Date()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
12
apps/api/src/types.ts
Normal file
12
apps/api/src/types.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export type ApiSuccess<T> = {
|
||||
data: T
|
||||
meta?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export type ApiError = {
|
||||
error: {
|
||||
code: string
|
||||
message: string
|
||||
details?: unknown
|
||||
}
|
||||
}
|
||||
41
apps/api/src/utils/date.ts
Normal file
41
apps/api/src/utils/date.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import dayjs from 'dayjs'
|
||||
import type { BillingIntervalUnit } from '@subtracker/shared'
|
||||
|
||||
export function toIsoDate(date: Date | string): string {
|
||||
return dayjs(date).format('YYYY-MM-DD')
|
||||
}
|
||||
|
||||
export function toDate(date: Date | string): Date {
|
||||
return dayjs(date).startOf('day').toDate()
|
||||
}
|
||||
|
||||
export function addInterval(date: Date | string, count: number, unit: BillingIntervalUnit): Date {
|
||||
const d = dayjs(date)
|
||||
switch (unit) {
|
||||
case 'day':
|
||||
return d.add(count, 'day').toDate()
|
||||
case 'week':
|
||||
return d.add(count, 'week').toDate()
|
||||
case 'month':
|
||||
return d.add(count, 'month').toDate()
|
||||
case 'quarter':
|
||||
return d.add(count * 3, 'month').toDate()
|
||||
case 'year':
|
||||
return d.add(count, 'year').toDate()
|
||||
default:
|
||||
return d.toDate()
|
||||
}
|
||||
}
|
||||
|
||||
export function isReminderDue(today: Date, nextRenewalDate: Date, notifyDaysBefore: number): boolean {
|
||||
const reminderDate = dayjs(nextRenewalDate).subtract(notifyDaysBefore, 'day').startOf('day')
|
||||
return dayjs(today).isSame(reminderDate) || dayjs(today).isAfter(reminderDate)
|
||||
}
|
||||
|
||||
export function isOverdue(today: Date, nextRenewalDate: Date): boolean {
|
||||
return dayjs(today).isAfter(dayjs(nextRenewalDate).endOf('day'))
|
||||
}
|
||||
|
||||
export function monthKey(date: Date | string): string {
|
||||
return dayjs(date).format('YYYY-MM')
|
||||
}
|
||||
33
apps/api/src/utils/money.ts
Normal file
33
apps/api/src/utils/money.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
export function roundMoney(value: number): number {
|
||||
return Number(value.toFixed(2))
|
||||
}
|
||||
|
||||
export function convertAmount(
|
||||
amount: number,
|
||||
fromCurrency: string,
|
||||
toCurrency: string,
|
||||
baseCurrency: string,
|
||||
rates: Record<string, number>
|
||||
): number {
|
||||
const from = fromCurrency.toUpperCase()
|
||||
const to = toCurrency.toUpperCase()
|
||||
const base = baseCurrency.toUpperCase()
|
||||
|
||||
if (from === to) return roundMoney(amount)
|
||||
|
||||
const normalizedRates: Record<string, number> = {
|
||||
...rates,
|
||||
[base]: 1
|
||||
}
|
||||
|
||||
const fromRate = normalizedRates[from]
|
||||
const toRate = normalizedRates[to]
|
||||
|
||||
if (!fromRate || !toRate) {
|
||||
throw new Error(`Unsupported currency conversion: ${from} -> ${to}`)
|
||||
}
|
||||
|
||||
const inBase = amount / fromRate
|
||||
const converted = inBase * toRate
|
||||
return roundMoney(converted)
|
||||
}
|
||||
5
apps/api/src/utils/webhook.ts
Normal file
5
apps/api/src/utils/webhook.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { createHmac } from 'node:crypto'
|
||||
|
||||
export function signWebhookPayload(secret: string, payload: string): string {
|
||||
return createHmac('sha256', secret).update(payload).digest('hex')
|
||||
}
|
||||
21
apps/api/tests/integration/health.test.ts
Normal file
21
apps/api/tests/integration/health.test.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import { buildApp } from '../../src/app'
|
||||
|
||||
describe('api health', () => {
|
||||
let app: Awaited<ReturnType<typeof buildApp>>
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await buildApp()
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('should return health status', async () => {
|
||||
const res = await app.inject({ method: 'GET', url: '/health' })
|
||||
expect(res.statusCode).toBe(200)
|
||||
const payload = res.json()
|
||||
expect(payload.ok).toBe(true)
|
||||
})
|
||||
})
|
||||
21
apps/api/tests/unit/date.test.ts
Normal file
21
apps/api/tests/unit/date.test.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { addInterval, isOverdue, isReminderDue, toIsoDate } from '../../src/utils/date'
|
||||
|
||||
describe('date utils', () => {
|
||||
it('should add month interval correctly', () => {
|
||||
const next = addInterval('2026-04-10', 1, 'month')
|
||||
expect(toIsoDate(next)).toBe('2026-05-10')
|
||||
})
|
||||
|
||||
it('should detect reminder due', () => {
|
||||
const today = new Date('2026-04-07')
|
||||
const renewal = new Date('2026-04-10')
|
||||
expect(isReminderDue(today, renewal, 3)).toBe(true)
|
||||
})
|
||||
|
||||
it('should detect overdue', () => {
|
||||
const today = new Date('2026-04-12')
|
||||
const renewal = new Date('2026-04-10')
|
||||
expect(isOverdue(today, renewal)).toBe(true)
|
||||
})
|
||||
})
|
||||
14
apps/api/tests/unit/money.test.ts
Normal file
14
apps/api/tests/unit/money.test.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { convertAmount } from '../../src/utils/money'
|
||||
|
||||
describe('money utils', () => {
|
||||
it('should convert USD to CNY with base USD rates', () => {
|
||||
const result = convertAmount(10, 'USD', 'CNY', 'USD', { CNY: 7.2, EUR: 0.92 })
|
||||
expect(result).toBe(72)
|
||||
})
|
||||
|
||||
it('should convert EUR to CNY with base USD rates', () => {
|
||||
const result = convertAmount(10, 'EUR', 'CNY', 'USD', { CNY: 7.2, EUR: 0.9 })
|
||||
expect(result).toBe(80)
|
||||
})
|
||||
})
|
||||
53
apps/api/tests/unit/subscription-order.test.ts
Normal file
53
apps/api/tests/unit/subscription-order.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const store = new Map<string, unknown>()
|
||||
|
||||
vi.mock('../../src/services/settings.service', () => ({
|
||||
getSetting: vi.fn(async <T>(key: string, fallbackValue: T) =>
|
||||
(store.has(key) ? (store.get(key) as T) : fallbackValue)
|
||||
),
|
||||
setSetting: vi.fn(async (key: string, value: unknown) => {
|
||||
store.set(key, value)
|
||||
})
|
||||
}))
|
||||
|
||||
import {
|
||||
appendSubscriptionOrder,
|
||||
getSubscriptionOrder,
|
||||
removeSubscriptionOrder,
|
||||
setSubscriptionOrder,
|
||||
sortSubscriptionsByOrder
|
||||
} from '../../src/services/subscription-order.service'
|
||||
|
||||
describe('subscription order service', () => {
|
||||
beforeEach(() => {
|
||||
store.clear()
|
||||
})
|
||||
|
||||
it('should persist unique ids in order', async () => {
|
||||
await setSubscriptionOrder(['sub-2', 'sub-1', 'sub-2', 'sub-3'])
|
||||
|
||||
await expect(getSubscriptionOrder()).resolves.toEqual(['sub-2', 'sub-1', 'sub-3'])
|
||||
})
|
||||
|
||||
it('should append and remove ids', async () => {
|
||||
await appendSubscriptionOrder('sub-1')
|
||||
await appendSubscriptionOrder('sub-2')
|
||||
await appendSubscriptionOrder('sub-1')
|
||||
await removeSubscriptionOrder('sub-2')
|
||||
|
||||
await expect(getSubscriptionOrder()).resolves.toEqual(['sub-1'])
|
||||
})
|
||||
|
||||
it('should sort subscriptions by stored custom order', async () => {
|
||||
await setSubscriptionOrder(['sub-3', 'sub-1'])
|
||||
|
||||
const rows = await sortSubscriptionsByOrder([
|
||||
{ id: 'sub-1', name: 'A' },
|
||||
{ id: 'sub-2', name: 'B' },
|
||||
{ id: 'sub-3', name: 'C' }
|
||||
])
|
||||
|
||||
expect(rows.map((row) => row.id)).toEqual(['sub-3', 'sub-1', 'sub-2'])
|
||||
})
|
||||
})
|
||||
11
apps/api/tsconfig.json
Normal file
11
apps/api/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts", "tests/**/*.ts", "prisma/seed.ts"]
|
||||
}
|
||||
8
apps/api/vitest.config.ts
Normal file
8
apps/api/vitest.config.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['tests/**/*.test.ts'],
|
||||
environment: 'node'
|
||||
}
|
||||
})
|
||||
12
apps/web/index.html
Normal file
12
apps/web/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>SubTracker</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
37
apps/web/package.json
Normal file
37
apps/web/package.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "@subtracker/web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc --noEmit && vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"lint": "vue-tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@subtracker/shared": "0.1.0",
|
||||
"@tanstack/vue-query": "^5.74.9",
|
||||
"@vicons/ionicons5": "^0.13.0",
|
||||
"@vueuse/core": "^13.0.0",
|
||||
"axios": "^1.8.4",
|
||||
"dayjs": "^1.11.13",
|
||||
"echarts": "^5.6.0",
|
||||
"naive-ui": "^2.41.0",
|
||||
"pinia": "^3.0.2",
|
||||
"vue": "^3.5.13",
|
||||
"vue-echarts": "^7.0.3",
|
||||
"vue-router": "^4.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.14.1",
|
||||
"@vitejs/plugin-vue": "^5.2.3",
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
"jsdom": "^26.0.0",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^6.2.6",
|
||||
"vitest": "^3.1.1",
|
||||
"vue-tsc": "^2.2.8"
|
||||
}
|
||||
}
|
||||
229
apps/web/src/App.vue
Normal file
229
apps/web/src/App.vue
Normal file
@@ -0,0 +1,229 @@
|
||||
<template>
|
||||
<n-config-provider :locale="zhCN" :date-locale="dateZhCN">
|
||||
<n-message-provider>
|
||||
<router-view v-if="isLoginPage" />
|
||||
<template v-else>
|
||||
<n-layout has-sider class="app-layout">
|
||||
<n-drawer v-model:show="mobileMenuVisible" placement="left" :width="260">
|
||||
<n-drawer-content title="SubTracker" closable body-content-style="padding: 8px 0;">
|
||||
<n-menu :options="menuOptions" :value="activeKey" @update:value="handleMobileMenuClick" />
|
||||
</n-drawer-content>
|
||||
</n-drawer>
|
||||
|
||||
<n-layout-sider
|
||||
v-if="!isMobile"
|
||||
bordered
|
||||
collapse-mode="width"
|
||||
:collapsed-width="64"
|
||||
:width="220"
|
||||
show-trigger
|
||||
>
|
||||
<div class="logo">
|
||||
<div class="logo__icon">
|
||||
<n-icon :size="18">
|
||||
<wallet-outline />
|
||||
</n-icon>
|
||||
</div>
|
||||
<span>SubTracker</span>
|
||||
</div>
|
||||
<n-menu :options="menuOptions" :value="activeKey" @update:value="handleMenuClick" />
|
||||
</n-layout-sider>
|
||||
|
||||
<n-layout>
|
||||
<n-layout-header bordered class="header">
|
||||
<div class="header__left">
|
||||
<n-button v-if="isMobile" quaternary circle @click="mobileMenuVisible = true">
|
||||
<template #icon>
|
||||
<n-icon><menu-outline /></n-icon>
|
||||
</template>
|
||||
</n-button>
|
||||
|
||||
<div class="header__content">
|
||||
<div class="header__title">
|
||||
<n-icon :size="18">
|
||||
<sparkles-outline />
|
||||
</n-icon>
|
||||
<strong>订阅管理台</strong>
|
||||
</div>
|
||||
<div v-if="!isCompact" class="card-muted">多币种 · 提醒 · 统计 · 日历</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<n-space align="center" :size="8" class="header__right">
|
||||
<n-tag type="info" round>{{ authStore.username || '未登录' }}</n-tag>
|
||||
<n-button quaternary @click="logout">退出登录</n-button>
|
||||
</n-space>
|
||||
</n-layout-header>
|
||||
|
||||
<n-layout-content :content-style="contentStyle">
|
||||
<router-view />
|
||||
</n-layout-content>
|
||||
</n-layout>
|
||||
</n-layout>
|
||||
</template>
|
||||
</n-message-provider>
|
||||
</n-config-provider>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, h, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useWindowSize } from '@vueuse/core'
|
||||
import {
|
||||
NButton,
|
||||
NConfigProvider,
|
||||
NDrawer,
|
||||
NDrawerContent,
|
||||
NIcon,
|
||||
NLayout,
|
||||
NLayoutContent,
|
||||
NLayoutHeader,
|
||||
NLayoutSider,
|
||||
NMenu,
|
||||
NMessageProvider,
|
||||
NSpace,
|
||||
NTag,
|
||||
dateZhCN,
|
||||
zhCN
|
||||
} from 'naive-ui'
|
||||
import type { MenuOption } from 'naive-ui'
|
||||
import {
|
||||
BarChartOutline,
|
||||
CalendarOutline,
|
||||
GridOutline,
|
||||
LayersOutline,
|
||||
MenuOutline,
|
||||
SettingsOutline,
|
||||
SparklesOutline,
|
||||
WalletOutline
|
||||
} from '@vicons/ionicons5'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
const mobileMenuVisible = ref(false)
|
||||
const { width } = useWindowSize()
|
||||
|
||||
function renderMenuIcon(icon: typeof GridOutline) {
|
||||
return () => h(NIcon, null, { default: () => h(icon) })
|
||||
}
|
||||
|
||||
const menuOptions: MenuOption[] = [
|
||||
{ label: '仪表盘', key: '/dashboard', icon: renderMenuIcon(GridOutline) },
|
||||
{ label: '订阅管理', key: '/subscriptions', icon: renderMenuIcon(LayersOutline) },
|
||||
{ label: '订阅日历', key: '/calendar', icon: renderMenuIcon(CalendarOutline) },
|
||||
{ label: '费用统计', key: '/statistics', icon: renderMenuIcon(BarChartOutline) },
|
||||
{ label: '系统设置', key: '/settings', icon: renderMenuIcon(SettingsOutline) }
|
||||
]
|
||||
|
||||
const activeKey = computed(() => route.path)
|
||||
const isLoginPage = computed(() => route.path === '/login')
|
||||
const isMobile = computed(() => width.value < 960)
|
||||
const isCompact = computed(() => width.value < 640)
|
||||
const contentStyle = computed(() => (isMobile.value ? 'padding: 12px;' : 'padding: 20px 24px;'))
|
||||
|
||||
function handleMenuClick(key: string) {
|
||||
router.push(key)
|
||||
}
|
||||
|
||||
function handleMobileMenuClick(key: string) {
|
||||
mobileMenuVisible.value = false
|
||||
handleMenuClick(key)
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
authStore.clearSession()
|
||||
await router.replace('/login')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.app-layout {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 56px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 0 18px;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.logo__icon {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(135deg, #2563eb 0%, #4f46e5 100%);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.header {
|
||||
min-height: 64px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 0 16px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.header__left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.header__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.header__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.card-muted {
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.header {
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.header__right {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.header :deep(.n-tag) {
|
||||
max-width: 120px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.header {
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.header__right {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
277
apps/web/src/components/CategoryFormModal.vue
Normal file
277
apps/web/src/components/CategoryFormModal.vue
Normal file
@@ -0,0 +1,277 @@
|
||||
<template>
|
||||
<n-modal
|
||||
:show="show"
|
||||
preset="card"
|
||||
:title="model ? '编辑分类' : '新增分类'"
|
||||
style="width: min(640px, calc(100vw - 24px))"
|
||||
@mask-click="close"
|
||||
@update:show="handleUpdateShow"
|
||||
>
|
||||
<n-form :model="form" label-placement="top">
|
||||
<n-form-item label="分类名称">
|
||||
<n-input v-model:value="form.name" placeholder="例如:云服务" />
|
||||
</n-form-item>
|
||||
|
||||
<n-grid :cols="2" :x-gap="16">
|
||||
<n-grid-item>
|
||||
<n-form-item label="颜色">
|
||||
<div class="color-field">
|
||||
<n-input v-model:value="form.color" placeholder="#3b82f6 或 rgb(59,130,246)" />
|
||||
<n-color-picker
|
||||
v-model:value="form.color"
|
||||
:modes="['hex', 'rgb']"
|
||||
:show-alpha="false"
|
||||
class="color-field__picker"
|
||||
/>
|
||||
</div>
|
||||
</n-form-item>
|
||||
</n-grid-item>
|
||||
|
||||
<n-grid-item>
|
||||
<n-form-item label="图标">
|
||||
<n-select
|
||||
v-model:value="form.icon"
|
||||
:options="iconOptions"
|
||||
:render-label="renderIconOption"
|
||||
filterable
|
||||
placeholder="选择图标"
|
||||
/>
|
||||
</n-form-item>
|
||||
</n-grid-item>
|
||||
</n-grid>
|
||||
|
||||
<n-grid :cols="2" :x-gap="16">
|
||||
<n-grid-item>
|
||||
<n-form-item label="图标预览">
|
||||
<div class="icon-preview">
|
||||
<n-icon :size="22">
|
||||
<component :is="selectedIconComponent" />
|
||||
</n-icon>
|
||||
<span>{{ selectedIconLabel }}</span>
|
||||
</div>
|
||||
</n-form-item>
|
||||
</n-grid-item>
|
||||
|
||||
<n-grid-item>
|
||||
<n-form-item label="排序">
|
||||
<n-input-number v-model:value="form.sortOrder" :min="0" style="width: 100%" />
|
||||
</n-form-item>
|
||||
</n-grid-item>
|
||||
</n-grid>
|
||||
|
||||
<n-space justify="end">
|
||||
<n-button @click="close">取消</n-button>
|
||||
<n-button type="primary" @click="submit">保存</n-button>
|
||||
</n-space>
|
||||
</n-form>
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, h, reactive, watch } from 'vue'
|
||||
import {
|
||||
AppsOutline,
|
||||
BriefcaseOutline,
|
||||
BuildOutline,
|
||||
CloudOutline,
|
||||
CodeSlashOutline,
|
||||
FilmOutline,
|
||||
GameControllerOutline,
|
||||
LaptopOutline,
|
||||
LibraryOutline,
|
||||
MusicalNotesOutline,
|
||||
RocketOutline,
|
||||
SchoolOutline,
|
||||
WalletOutline
|
||||
} from '@vicons/ionicons5'
|
||||
import {
|
||||
NButton,
|
||||
NColorPicker,
|
||||
NForm,
|
||||
NFormItem,
|
||||
NGrid,
|
||||
NGridItem,
|
||||
NIcon,
|
||||
NInput,
|
||||
NInputNumber,
|
||||
NModal,
|
||||
NSelect,
|
||||
NSpace
|
||||
} from 'naive-ui'
|
||||
import type { Category } from '@/types/api'
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
model?: Category | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
submit: [payload: { name: string; color: string; icon: string; sortOrder: number }, editingId?: string]
|
||||
}>()
|
||||
|
||||
const iconMap = {
|
||||
'apps-outline': { label: '通用应用', component: AppsOutline },
|
||||
'briefcase-outline': { label: '工作事务', component: BriefcaseOutline },
|
||||
'build-outline': { label: '工具服务', component: BuildOutline },
|
||||
'cloud-outline': { label: '云服务', component: CloudOutline },
|
||||
'code-slash-outline': { label: '开发工具', component: CodeSlashOutline },
|
||||
'film-outline': { label: '影音娱乐', component: FilmOutline },
|
||||
'game-controller-outline': { label: '游戏服务', component: GameControllerOutline },
|
||||
'laptop-outline': { label: '设备软件', component: LaptopOutline },
|
||||
'library-outline': { label: '学习阅读', component: LibraryOutline },
|
||||
'musical-notes-outline': { label: '音乐播客', component: MusicalNotesOutline },
|
||||
'rocket-outline': { label: '效率办公', component: RocketOutline },
|
||||
'school-outline': { label: '教育培训', component: SchoolOutline },
|
||||
'wallet-outline': { label: '支付账单', component: WalletOutline }
|
||||
} as const
|
||||
|
||||
const iconOptions = Object.entries(iconMap).map(([value, item]) => ({
|
||||
label: item.label,
|
||||
value
|
||||
}))
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
color: '#3b82f6',
|
||||
icon: 'apps-outline',
|
||||
sortOrder: 0
|
||||
})
|
||||
|
||||
const selectedIcon = computed(() => iconMap[form.icon as keyof typeof iconMap] ?? iconMap['apps-outline'])
|
||||
const selectedIconComponent = computed(() => selectedIcon.value.component)
|
||||
const selectedIconLabel = computed(() => selectedIcon.value.label)
|
||||
|
||||
watch(
|
||||
() => props.model,
|
||||
(model) => {
|
||||
if (!model) {
|
||||
reset()
|
||||
return
|
||||
}
|
||||
|
||||
form.name = model.name
|
||||
form.color = model.color
|
||||
form.icon = model.icon
|
||||
form.sortOrder = model.sortOrder
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
function renderIconOption(option: { label: string; value: string }) {
|
||||
const icon = iconMap[option.value as keyof typeof iconMap]
|
||||
|
||||
return h(
|
||||
'div',
|
||||
{
|
||||
style: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
minWidth: '0'
|
||||
}
|
||||
},
|
||||
[
|
||||
h(
|
||||
'div',
|
||||
{
|
||||
style: {
|
||||
width: '28px',
|
||||
height: '28px',
|
||||
borderRadius: '8px',
|
||||
background: '#f8fafc',
|
||||
border: '1px solid #e2e8f0',
|
||||
color: '#334155',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: '0'
|
||||
}
|
||||
},
|
||||
[h(NIcon, { size: 16 }, { default: () => h(icon.component) })]
|
||||
),
|
||||
h(
|
||||
'div',
|
||||
{
|
||||
style: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '1px',
|
||||
minWidth: '0'
|
||||
}
|
||||
},
|
||||
[
|
||||
h(
|
||||
'span',
|
||||
{
|
||||
style: {
|
||||
color: '#0f172a',
|
||||
lineHeight: '1.2'
|
||||
}
|
||||
},
|
||||
option.label
|
||||
),
|
||||
h(
|
||||
'span',
|
||||
{
|
||||
style: {
|
||||
color: '#94a3b8',
|
||||
fontSize: '12px',
|
||||
lineHeight: '1.2'
|
||||
}
|
||||
},
|
||||
option.value
|
||||
)
|
||||
]
|
||||
)
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
function reset() {
|
||||
form.name = ''
|
||||
form.color = '#3b82f6'
|
||||
form.icon = 'apps-outline'
|
||||
form.sortOrder = 0
|
||||
}
|
||||
|
||||
function close() {
|
||||
reset()
|
||||
emit('close')
|
||||
}
|
||||
|
||||
function submit() {
|
||||
emit('submit', { ...form }, props.model?.id)
|
||||
reset()
|
||||
}
|
||||
|
||||
function handleUpdateShow(value: boolean) {
|
||||
if (!value) {
|
||||
close()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.color-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.color-field__picker {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.icon-preview {
|
||||
height: 40px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 10px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: #334155;
|
||||
background: #f8fafc;
|
||||
}
|
||||
</style>
|
||||
268
apps/web/src/components/CategoryManageModal.vue
Normal file
268
apps/web/src/components/CategoryManageModal.vue
Normal file
@@ -0,0 +1,268 @@
|
||||
<template>
|
||||
<n-modal
|
||||
:show="show"
|
||||
preset="card"
|
||||
title="分类管理"
|
||||
style="width: min(820px, calc(100vw - 24px))"
|
||||
@mask-click="$emit('close')"
|
||||
@update:show="handleUpdateShow"
|
||||
>
|
||||
<n-space vertical :size="16">
|
||||
<n-space justify="space-between" align="center">
|
||||
<n-text depth="3">在这里统一新增、编辑和删除分类。</n-text>
|
||||
<n-button type="primary" @click="openCreate">新增分类</n-button>
|
||||
</n-space>
|
||||
|
||||
<n-data-table
|
||||
:columns="columns"
|
||||
:data="categories"
|
||||
:pagination="{ pageSize: 8 }"
|
||||
:bordered="false"
|
||||
/>
|
||||
</n-space>
|
||||
|
||||
<category-form-modal
|
||||
:show="showFormModal"
|
||||
:model="editing"
|
||||
@close="closeFormModal"
|
||||
@submit="handleSubmit"
|
||||
/>
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, h, ref } from 'vue'
|
||||
import { NButton, NDataTable, NIcon, NModal, NPopconfirm, NSpace, NTag, NText } from 'naive-ui'
|
||||
import { PricetagsOutline } from '@vicons/ionicons5'
|
||||
import CategoryFormModal from '@/components/CategoryFormModal.vue'
|
||||
import type { Category } from '@/types/api'
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
categories: Category[]
|
||||
subscriptionCounts?: Record<string, number>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
create: [payload: { name: string; color: string; icon: string; sortOrder: number }]
|
||||
update: [payload: { name: string; color: string; icon: string; sortOrder: number }, id: string]
|
||||
delete: [category: Category]
|
||||
}>()
|
||||
|
||||
const showFormModal = ref(false)
|
||||
const editing = ref<Category | null>(null)
|
||||
|
||||
const columns = computed(() => [
|
||||
{
|
||||
title: '分类',
|
||||
key: 'name',
|
||||
render: (row: Category) =>
|
||||
h(
|
||||
'div',
|
||||
{
|
||||
style: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px'
|
||||
}
|
||||
},
|
||||
[
|
||||
h(
|
||||
'div',
|
||||
{
|
||||
style: {
|
||||
width: '14px',
|
||||
height: '14px',
|
||||
borderRadius: '999px',
|
||||
background: row.color,
|
||||
flexShrink: '0'
|
||||
}
|
||||
},
|
||||
undefined
|
||||
),
|
||||
h(
|
||||
NIcon,
|
||||
{
|
||||
size: 18,
|
||||
color: '#475569'
|
||||
},
|
||||
{ default: () => h(resolveCategoryIcon(row.icon)) }
|
||||
),
|
||||
h(
|
||||
'div',
|
||||
{
|
||||
style: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '2px'
|
||||
}
|
||||
},
|
||||
[
|
||||
h(
|
||||
'span',
|
||||
{
|
||||
style: {
|
||||
fontWeight: '600',
|
||||
color: '#0f172a'
|
||||
}
|
||||
},
|
||||
row.name
|
||||
),
|
||||
h(
|
||||
'span',
|
||||
{
|
||||
style: {
|
||||
fontSize: '12px',
|
||||
color: '#94a3b8'
|
||||
}
|
||||
},
|
||||
row.icon
|
||||
)
|
||||
]
|
||||
)
|
||||
]
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '颜色',
|
||||
key: 'color',
|
||||
width: 140,
|
||||
render: (row: Category) =>
|
||||
h(
|
||||
NTag,
|
||||
{
|
||||
bordered: false,
|
||||
color: {
|
||||
color: row.color,
|
||||
textColor: '#fff'
|
||||
}
|
||||
},
|
||||
{ default: () => row.color }
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '排序',
|
||||
key: 'sortOrder',
|
||||
width: 90
|
||||
},
|
||||
{
|
||||
title: '订阅数',
|
||||
key: 'subscriptionCount',
|
||||
width: 100,
|
||||
render: (row: Category) => props.subscriptionCounts?.[row.id] ?? 0
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 180,
|
||||
render: (row: Category) =>
|
||||
h(
|
||||
NSpace,
|
||||
{ size: 8 },
|
||||
{
|
||||
default: () => [
|
||||
h(
|
||||
NButton,
|
||||
{
|
||||
size: 'small',
|
||||
onClick: () => openEdit(row)
|
||||
},
|
||||
{ default: () => '编辑' }
|
||||
),
|
||||
h(
|
||||
NPopconfirm,
|
||||
{
|
||||
positiveText: '删除',
|
||||
negativeText: '取消',
|
||||
onPositiveClick: () => emit('delete', row)
|
||||
},
|
||||
{
|
||||
trigger: () =>
|
||||
h(
|
||||
NButton,
|
||||
{
|
||||
size: 'small',
|
||||
type: 'error',
|
||||
ghost: true
|
||||
},
|
||||
{ default: () => '删除' }
|
||||
),
|
||||
default: () =>
|
||||
(props.subscriptionCounts?.[row.id] ?? 0) > 0
|
||||
? '删除后,该分类下的订阅会变成未分类,确认继续?'
|
||||
: '确认删除该分类?'
|
||||
}
|
||||
)
|
||||
]
|
||||
}
|
||||
)
|
||||
}
|
||||
])
|
||||
|
||||
function openCreate() {
|
||||
editing.value = null
|
||||
showFormModal.value = true
|
||||
}
|
||||
|
||||
function openEdit(category: Category) {
|
||||
editing.value = category
|
||||
showFormModal.value = true
|
||||
}
|
||||
|
||||
function closeFormModal() {
|
||||
showFormModal.value = false
|
||||
editing.value = null
|
||||
}
|
||||
|
||||
function handleSubmit(payload: { name: string; color: string; icon: string; sortOrder: number }, editingId?: string) {
|
||||
if (editingId) {
|
||||
emit('update', payload, editingId)
|
||||
} else {
|
||||
emit('create', payload)
|
||||
}
|
||||
closeFormModal()
|
||||
}
|
||||
|
||||
function handleUpdateShow(value: boolean) {
|
||||
if (!value) {
|
||||
emit('close')
|
||||
}
|
||||
}
|
||||
|
||||
import {
|
||||
AppsOutline,
|
||||
BriefcaseOutline,
|
||||
BuildOutline,
|
||||
CloudOutline,
|
||||
CodeSlashOutline,
|
||||
FilmOutline,
|
||||
GameControllerOutline,
|
||||
LaptopOutline,
|
||||
LibraryOutline,
|
||||
MusicalNotesOutline,
|
||||
RocketOutline,
|
||||
SchoolOutline,
|
||||
WalletOutline
|
||||
} from '@vicons/ionicons5'
|
||||
|
||||
const iconMap = {
|
||||
'apps-outline': AppsOutline,
|
||||
'briefcase-outline': BriefcaseOutline,
|
||||
'build-outline': BuildOutline,
|
||||
'cloud-outline': CloudOutline,
|
||||
'code-slash-outline': CodeSlashOutline,
|
||||
'film-outline': FilmOutline,
|
||||
'game-controller-outline': GameControllerOutline,
|
||||
'laptop-outline': LaptopOutline,
|
||||
'library-outline': LibraryOutline,
|
||||
'musical-notes-outline': MusicalNotesOutline,
|
||||
'rocket-outline': RocketOutline,
|
||||
'school-outline': SchoolOutline,
|
||||
'wallet-outline': WalletOutline
|
||||
} as const
|
||||
|
||||
function resolveCategoryIcon(icon: string) {
|
||||
return iconMap[icon as keyof typeof iconMap] ?? PricetagsOutline
|
||||
}
|
||||
</script>
|
||||
30
apps/web/src/components/ChartView.vue
Normal file
30
apps/web/src/components/ChartView.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<template>
|
||||
<component :is="VChart" :option="option" autoresize class="chart-view" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { defineAsyncComponent } from 'vue'
|
||||
import { use } from 'echarts/core'
|
||||
import { CanvasRenderer } from 'echarts/renderers'
|
||||
import { BarChart, LineChart, PieChart } from 'echarts/charts'
|
||||
import { GridComponent, LegendComponent, TitleComponent, TooltipComponent } from 'echarts/components'
|
||||
|
||||
use([CanvasRenderer, BarChart, LineChart, PieChart, TooltipComponent, GridComponent, LegendComponent, TitleComponent])
|
||||
|
||||
const VChart = defineAsyncComponent(() => import('vue-echarts'))
|
||||
|
||||
defineProps<{ option: Record<string, unknown> }>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.chart-view {
|
||||
width: 100%;
|
||||
height: 320px;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.chart-view {
|
||||
height: 260px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
86
apps/web/src/components/PageHeader.vue
Normal file
86
apps/web/src/components/PageHeader.vue
Normal file
@@ -0,0 +1,86 @@
|
||||
<template>
|
||||
<div class="page-header">
|
||||
<div class="page-header__icon" :style="{ background: iconBackground }">
|
||||
<n-icon :size="22">
|
||||
<component :is="icon" />
|
||||
</n-icon>
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="page-title">{{ title }}</h1>
|
||||
<p class="page-subtitle">{{ subtitle }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Component } from 'vue'
|
||||
import { NIcon } from 'naive-ui'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
title: string
|
||||
subtitle: string
|
||||
icon: Component
|
||||
iconBackground?: string
|
||||
}>(),
|
||||
{
|
||||
iconBackground: 'linear-gradient(135deg, #2563eb 0%, #4f46e5 100%)'
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 14px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.page-header__icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
box-shadow: 0 10px 30px rgba(37, 99, 235, 0.18);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
margin: 6px 0 0;
|
||||
color: #64748b;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.page-header {
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.page-header__icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
17
apps/web/src/components/StatCard.test.ts
Normal file
17
apps/web/src/components/StatCard.test.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import StatCard from '@/components/StatCard.vue'
|
||||
|
||||
describe('StatCard', () => {
|
||||
it('renders value and label', () => {
|
||||
const wrapper = mount(StatCard, {
|
||||
props: {
|
||||
label: '活跃订阅',
|
||||
value: 12
|
||||
}
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('活跃订阅')
|
||||
expect(wrapper.text()).toContain('12')
|
||||
})
|
||||
})
|
||||
74
apps/web/src/components/StatCard.vue
Normal file
74
apps/web/src/components/StatCard.vue
Normal file
@@ -0,0 +1,74 @@
|
||||
<template>
|
||||
<n-card size="small" :bordered="false" class="stat-card">
|
||||
<div class="stat-card__top">
|
||||
<div class="label">{{ label }}</div>
|
||||
<div v-if="icon" class="stat-card__icon" :style="{ background: iconBackground }">
|
||||
<n-icon :size="18">
|
||||
<component :is="icon" />
|
||||
</n-icon>
|
||||
</div>
|
||||
</div>
|
||||
<div class="value">{{ value }}</div>
|
||||
<div v-if="suffix" class="suffix">{{ suffix }}</div>
|
||||
</n-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Component } from 'vue'
|
||||
import { NCard, NIcon } from 'naive-ui'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
label: string
|
||||
value: string | number
|
||||
suffix?: string
|
||||
icon?: Component
|
||||
iconBackground?: string
|
||||
}>(),
|
||||
{
|
||||
iconBackground: 'rgba(37, 99, 235, 0.12)'
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.stat-card {
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.stat-card__top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.stat-card__icon {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.label {
|
||||
color: #6b7280;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.value {
|
||||
margin-top: 8px;
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.suffix {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
</style>
|
||||
175
apps/web/src/components/SubscriptionAiModal.vue
Normal file
175
apps/web/src/components/SubscriptionAiModal.vue
Normal file
@@ -0,0 +1,175 @@
|
||||
<template>
|
||||
<n-modal :show="show" preset="card" title="AI 识别订阅" style="width: 720px" @mask-click="emit('close')" @update:show="handleUpdateShow">
|
||||
<n-space vertical>
|
||||
<n-alert type="info" :show-icon="false">
|
||||
支持输入文本、上传图片或直接粘贴截图。若当前模型不支持图片识别,将自动回退到本地 OCR 提取文本后再交给模型清洗。识别结果只会回填表单,不会自动保存。
|
||||
</n-alert>
|
||||
|
||||
<n-form label-placement="top">
|
||||
<n-form-item label="文本内容">
|
||||
<n-input
|
||||
v-model:value="text"
|
||||
type="textarea"
|
||||
:autosize="{ minRows: 4, maxRows: 8 }"
|
||||
placeholder="粘贴订阅邮件、支付记录、订单文本等"
|
||||
/>
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item label="图片">
|
||||
<div class="ai-upload-box" @paste="handlePaste">
|
||||
<input ref="fileInputRef" type="file" accept="image/*" class="hidden-input" @change="handleFileChange" />
|
||||
<n-space vertical>
|
||||
<n-space>
|
||||
<n-button @click="pickFile">上传图片</n-button>
|
||||
<n-button quaternary @click="clearImage">清空图片</n-button>
|
||||
</n-space>
|
||||
<div class="card-muted">也可以直接在此区域粘贴截图</div>
|
||||
<img v-if="imagePreview" :src="imagePreview" alt="识别图片预览" class="ai-upload-box__preview" />
|
||||
</n-space>
|
||||
</div>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
|
||||
<n-space justify="space-between">
|
||||
<div v-if="result" class="card-muted">置信度:{{ ((result.confidence ?? 0) * 100).toFixed(0) }}%</div>
|
||||
<n-space>
|
||||
<n-button @click="emit('close')">关闭</n-button>
|
||||
<n-button type="primary" :loading="loading" @click="recognize">开始识别</n-button>
|
||||
<n-button type="success" :disabled="!result" @click="applyResult">应用结果</n-button>
|
||||
</n-space>
|
||||
</n-space>
|
||||
|
||||
<n-card v-if="result" size="small" embedded title="识别结果">
|
||||
<pre class="ai-result">{{ prettyResult }}</pre>
|
||||
</n-card>
|
||||
</n-space>
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { NAlert, NButton, NCard, NForm, NFormItem, NInput, NModal, NSpace, useMessage } from 'naive-ui'
|
||||
import { api } from '@/composables/api'
|
||||
import type { AiRecognitionResult } from '@/types/api'
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
apply: [result: AiRecognitionResult]
|
||||
}>()
|
||||
|
||||
const message = useMessage()
|
||||
const fileInputRef = ref<HTMLInputElement | null>(null)
|
||||
const text = ref('')
|
||||
const imageBase64 = ref('')
|
||||
const imagePreview = ref('')
|
||||
const imageFilename = ref('')
|
||||
const imageMimeType = ref('image/png')
|
||||
const loading = ref(false)
|
||||
const result = ref<AiRecognitionResult | null>(null)
|
||||
|
||||
const prettyResult = computed(() => JSON.stringify(result.value, null, 2))
|
||||
|
||||
function handleUpdateShow(value: boolean) {
|
||||
if (!value) emit('close')
|
||||
}
|
||||
|
||||
function pickFile() {
|
||||
fileInputRef.value?.click()
|
||||
}
|
||||
|
||||
function clearImage() {
|
||||
imageBase64.value = ''
|
||||
imagePreview.value = ''
|
||||
imageFilename.value = ''
|
||||
}
|
||||
|
||||
function readFile(file: File) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
const raw = String(reader.result ?? '')
|
||||
imagePreview.value = raw
|
||||
imageBase64.value = raw.includes(',') ? raw.split(',')[1] : raw
|
||||
imageFilename.value = file.name
|
||||
imageMimeType.value = file.type || 'image/png'
|
||||
resolve()
|
||||
}
|
||||
reader.onerror = () => reject(reader.error)
|
||||
reader.readAsDataURL(file)
|
||||
})
|
||||
}
|
||||
|
||||
async function handleFileChange(event: Event) {
|
||||
const file = (event.target as HTMLInputElement).files?.[0]
|
||||
if (!file) return
|
||||
await readFile(file)
|
||||
}
|
||||
|
||||
async function handlePaste(event: ClipboardEvent) {
|
||||
const file = event.clipboardData?.files?.[0]
|
||||
if (!file) return
|
||||
await readFile(file)
|
||||
}
|
||||
|
||||
async function recognize() {
|
||||
if (!text.value.trim() && !imageBase64.value) {
|
||||
message.warning('请先输入文本或上传图片')
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
result.value = await api.recognizeSubscriptionByAi({
|
||||
text: text.value.trim() || undefined,
|
||||
imageBase64: imageBase64.value || undefined,
|
||||
filename: imageFilename.value || undefined,
|
||||
mimeType: imageMimeType.value || undefined
|
||||
})
|
||||
message.success('识别完成')
|
||||
} catch (error) {
|
||||
result.value = null
|
||||
message.error(error instanceof Error ? error.message : 'AI 识别失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function applyResult() {
|
||||
if (!result.value) return
|
||||
emit('apply', result.value)
|
||||
emit('close')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.hidden-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ai-upload-box {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border: 1px dashed #cbd5e1;
|
||||
border-radius: 12px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.ai-upload-box__preview {
|
||||
max-width: 100%;
|
||||
max-height: 240px;
|
||||
border-radius: 10px;
|
||||
object-fit: contain;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.ai-result {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
118
apps/web/src/components/SubscriptionDetailDrawer.vue
Normal file
118
apps/web/src/components/SubscriptionDetailDrawer.vue
Normal file
@@ -0,0 +1,118 @@
|
||||
<template>
|
||||
<n-drawer :show="show" :width="drawerWidth" @mask-click="emit('close')">
|
||||
<n-drawer-content title="订阅详情" closable>
|
||||
<n-empty v-if="!detail" description="暂无数据" />
|
||||
<template v-else>
|
||||
<n-space vertical :size="16">
|
||||
<n-space align="center">
|
||||
<img v-if="detail.logoUrl" :src="resolveLogoUrl(detail.logoUrl)" alt="logo" class="detail-logo" />
|
||||
<div v-else class="detail-logo detail-logo--fallback">{{ detail.name.slice(0, 1).toUpperCase() }}</div>
|
||||
<div>
|
||||
<div class="detail-title">{{ detail.name }}</div>
|
||||
<div v-if="detail.websiteUrl" class="detail-site">
|
||||
<a :href="detail.websiteUrl" target="_blank" rel="noreferrer">{{ detail.websiteUrl }}</a>
|
||||
</div>
|
||||
</div>
|
||||
</n-space>
|
||||
|
||||
<n-descriptions label-placement="left" :column="descriptionColumns" bordered>
|
||||
<n-descriptions-item label="名称">{{ detail.name }}</n-descriptions-item>
|
||||
<n-descriptions-item label="状态">
|
||||
<n-tag :type="getSubscriptionStatusTagType(detail.status)">{{ getSubscriptionStatusText(detail.status) }}</n-tag>
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="分类">{{ detail.category?.name ?? '未分类' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="支付频率">每 {{ detail.billingIntervalCount }} {{ unitLabel(detail.billingIntervalUnit) }}</n-descriptions-item>
|
||||
<n-descriptions-item label="开始日期">{{ formatDate(detail.startDate) }}</n-descriptions-item>
|
||||
<n-descriptions-item label="下次续费">{{ formatDate(detail.nextRenewalDate) }}</n-descriptions-item>
|
||||
<n-descriptions-item label="原始金额">{{ formatMoney(detail.amount, detail.currency) }}</n-descriptions-item>
|
||||
<n-descriptions-item label="提前提醒">{{ detail.notifyDaysBefore }} 天</n-descriptions-item>
|
||||
<n-descriptions-item label="提醒通知">{{ detail.webhookEnabled ? '已启用' : '未启用' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="创建时间">{{ formatDateTime(detail.createdAt) }}</n-descriptions-item>
|
||||
</n-descriptions>
|
||||
|
||||
<n-card title="描述">
|
||||
{{ detail.description || '暂无描述' }}
|
||||
</n-card>
|
||||
|
||||
<n-card title="备注">
|
||||
{{ detail.notes || '暂无备注' }}
|
||||
</n-card>
|
||||
</n-space>
|
||||
</template>
|
||||
</n-drawer-content>
|
||||
</n-drawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import dayjs from 'dayjs'
|
||||
import { computed } from 'vue'
|
||||
import { useWindowSize } from '@vueuse/core'
|
||||
import { NCard, NDescriptions, NDescriptionsItem, NDrawer, NDrawerContent, NEmpty, NSpace, NTag } from 'naive-ui'
|
||||
import type { SubscriptionDetail } from '@/types/api'
|
||||
import { getSubscriptionStatusTagType, getSubscriptionStatusText } from '@/utils/subscription-status'
|
||||
import { resolveLogoUrl } from '@/utils/logo'
|
||||
|
||||
const emit = defineEmits<{ close: [] }>()
|
||||
|
||||
defineProps<{
|
||||
show: boolean
|
||||
detail: SubscriptionDetail | null
|
||||
}>()
|
||||
|
||||
const { width } = useWindowSize()
|
||||
const drawerWidth = computed(() => (width.value < 760 ? '100%' : 720))
|
||||
const descriptionColumns = computed(() => (width.value < 760 ? 1 : 2))
|
||||
|
||||
function formatMoney(amount: number, currency: string) {
|
||||
return `${currency} ${amount.toFixed(2)}`
|
||||
}
|
||||
|
||||
function formatDate(value: string) {
|
||||
return dayjs(value).format('YYYY-MM-DD')
|
||||
}
|
||||
|
||||
function formatDateTime(value: string) {
|
||||
return dayjs(value).format('YYYY-MM-DD HH:mm:ss')
|
||||
}
|
||||
|
||||
function unitLabel(unit: string) {
|
||||
return {
|
||||
day: '天',
|
||||
week: '周',
|
||||
month: '月',
|
||||
quarter: '季',
|
||||
year: '年'
|
||||
}[unit] ?? unit
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.detail-logo {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 14px;
|
||||
object-fit: contain;
|
||||
border: 1px solid #e5e7eb;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.detail-logo--fallback {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #eff6ff;
|
||||
color: #2563eb;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.detail-title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.detail-site a {
|
||||
color: #2563eb;
|
||||
word-break: break-all;
|
||||
}
|
||||
</style>
|
||||
873
apps/web/src/components/SubscriptionFormModal.vue
Normal file
873
apps/web/src/components/SubscriptionFormModal.vue
Normal file
@@ -0,0 +1,873 @@
|
||||
<template>
|
||||
<n-modal
|
||||
:show="show"
|
||||
preset="card"
|
||||
title="订阅信息"
|
||||
style="width: min(920px, calc(100vw - 24px))"
|
||||
@mask-click="close"
|
||||
@update:show="handleUpdateShow"
|
||||
>
|
||||
<n-form :model="form" label-placement="top">
|
||||
<div class="name-logo-row">
|
||||
<n-form-item label="名称" class="name-logo-row__name">
|
||||
<n-input v-model:value="form.name" placeholder="例如:GitHub Pro" />
|
||||
</n-form-item>
|
||||
|
||||
<div class="logo-dock">
|
||||
<div class="logo-dock__row">
|
||||
<div class="logo-dock__preview-wrap">
|
||||
<button type="button" class="logo-dock__preview" @click="pickLogoFile">
|
||||
<img v-if="resolvedLogoUrl" :src="resolvedLogoUrl" alt="logo" class="logo-dock__image" />
|
||||
<div v-else class="logo-dock__placeholder">
|
||||
<span>{{ form.name.trim() ? '点击上传' : 'Logo' }}</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button v-if="form.logoUrl" type="button" class="logo-dock__clear" @click.stop="clearLogo">
|
||||
<n-icon :component="CloseOutline" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<n-button circle tertiary type="primary" @click.stop="openLogoPanel">
|
||||
<template #icon>
|
||||
<n-icon :component="SearchOutline" />
|
||||
</template>
|
||||
</n-button>
|
||||
</div>
|
||||
|
||||
<input ref="logoFileInputRef" type="file" accept="image/*" class="hidden-input" @change="handleLogoFileChange" />
|
||||
|
||||
<div v-if="showLogoPanel" class="logo-panel">
|
||||
<div class="logo-panel__header">
|
||||
<span>选择 Logo</span>
|
||||
<button type="button" class="logo-panel__close" @click="showLogoPanel = false">
|
||||
<n-icon :component="CloseOutline" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<n-tabs v-model:value="logoPanelTab" type="segment" animated class="logo-panel__tabs">
|
||||
<n-tab-pane :name="LOGO_TAB_WEB" :tab="`网络搜索 (${logoCandidates.length})`">
|
||||
<div v-if="searchingLogoCandidates" class="logo-panel__state">
|
||||
<n-spin size="small" />
|
||||
<span>正在搜索 Logo...</span>
|
||||
</div>
|
||||
|
||||
<div v-else-if="logoCandidates.length" class="logo-panel__list">
|
||||
<button
|
||||
v-for="item in logoCandidates"
|
||||
:key="`${item.source}-${item.logoUrl}`"
|
||||
type="button"
|
||||
class="logo-panel__item"
|
||||
@click="applyRemoteLogoCandidate(item)"
|
||||
>
|
||||
<img :src="item.logoUrl" :alt="item.label" class="logo-panel__item-image" />
|
||||
<span class="logo-panel__item-label">{{ item.label }}</span>
|
||||
<span class="logo-panel__item-meta">
|
||||
{{ formatLogoSource(item.source) }}
|
||||
<template v-if="item.width && item.height"> · {{ item.width }}×{{ item.height }}</template>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<n-empty v-else description="当前没有可用的网络搜索结果" size="small" class="logo-panel__empty" />
|
||||
</n-tab-pane>
|
||||
|
||||
<n-tab-pane :name="LOGO_TAB_LIBRARY" :tab="`本地已保存 (${localLogoLibrary.length})`">
|
||||
<div v-if="loadingLocalLogoLibrary" class="logo-panel__state">
|
||||
<n-spin size="small" />
|
||||
<span>正在加载本地 Logo...</span>
|
||||
</div>
|
||||
|
||||
<div v-else-if="localLogoLibrary.length" class="logo-panel__list">
|
||||
<div v-for="item in localLogoLibrary" :key="item.logoUrl" class="logo-panel__item-wrap">
|
||||
<button
|
||||
v-if="(item.usageCount ?? 0) === 0 && item.filename"
|
||||
type="button"
|
||||
class="logo-panel__delete"
|
||||
@click.stop="deleteLocalLogo(item)"
|
||||
>
|
||||
<n-icon :component="CloseOutline" />
|
||||
</button>
|
||||
|
||||
<button type="button" class="logo-panel__item" @click="applyLocalLogoCandidate(item)">
|
||||
<img :src="buildAbsoluteLogoUrl(item.logoUrl)" :alt="item.label" class="logo-panel__item-image" />
|
||||
<span class="logo-panel__item-label">{{ item.label }}</span>
|
||||
<span class="logo-panel__item-meta">
|
||||
{{ formatLogoSource(item.source) }}
|
||||
<template v-if="item.usageCount !== undefined"> · 已用 {{ item.usageCount }} 次</template>
|
||||
</span>
|
||||
<span v-if="item.relatedSubscriptionNames?.length" class="logo-panel__item-related">
|
||||
{{ item.relatedSubscriptionNames.join(' / ') }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<n-empty v-else description="本地还没有可复用的 Logo" size="small" class="logo-panel__empty" />
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<n-grid :cols="layoutCols" :x-gap="16" :y-gap="8">
|
||||
<n-grid-item>
|
||||
<n-form-item label="分类">
|
||||
<n-select v-model:value="form.categoryId" :options="categoryOptions" clearable placeholder="选择分类" />
|
||||
</n-form-item>
|
||||
</n-grid-item>
|
||||
<n-grid-item>
|
||||
<n-form-item label="官网 / 平台地址">
|
||||
<n-input v-model:value="form.websiteUrl" placeholder="https://example.com" />
|
||||
</n-form-item>
|
||||
</n-grid-item>
|
||||
</n-grid>
|
||||
|
||||
<n-form-item label="描述">
|
||||
<n-input
|
||||
v-model:value="form.description"
|
||||
type="textarea"
|
||||
:autosize="{ minRows: 2, maxRows: 4 }"
|
||||
placeholder="可选,简单记录订阅用途"
|
||||
/>
|
||||
</n-form-item>
|
||||
|
||||
<n-grid :cols="moneyCols" :x-gap="16" :y-gap="8">
|
||||
<n-grid-item>
|
||||
<n-form-item label="金额">
|
||||
<n-input-number v-model:value="form.amount" :min="0" :precision="2" style="width: 100%" placeholder="输入金额" />
|
||||
</n-form-item>
|
||||
</n-grid-item>
|
||||
<n-grid-item>
|
||||
<n-form-item label="货币">
|
||||
<n-select v-model:value="form.currency" :options="currencyOptions" filterable placeholder="选择货币" />
|
||||
</n-form-item>
|
||||
</n-grid-item>
|
||||
<n-grid-item>
|
||||
<n-form-item label="频率">
|
||||
<n-select v-model:value="form.billingIntervalCount" :options="frequencyOptions" placeholder="选择频率" />
|
||||
</n-form-item>
|
||||
</n-grid-item>
|
||||
<n-grid-item>
|
||||
<n-form-item label="单位">
|
||||
<n-select v-model:value="form.billingIntervalUnit" :options="intervalOptions" placeholder="选择单位" />
|
||||
</n-form-item>
|
||||
</n-grid-item>
|
||||
</n-grid>
|
||||
|
||||
<n-grid :cols="dateCols" :x-gap="16" :y-gap="8">
|
||||
<n-grid-item>
|
||||
<n-form-item label="开始日期">
|
||||
<n-date-picker v-model:value="form.startDateTs" type="date" style="width: 100%" clearable />
|
||||
</n-form-item>
|
||||
</n-grid-item>
|
||||
<n-grid-item>
|
||||
<n-form-item label="下次续费">
|
||||
<n-date-picker v-model:value="form.nextRenewalDateTs" type="date" style="width: 100%" clearable />
|
||||
</n-form-item>
|
||||
</n-grid-item>
|
||||
<n-grid-item>
|
||||
<n-form-item label="提醒天数">
|
||||
<n-select v-model:value="form.notifyDaysBefore" :options="notifyDayOptions" placeholder="选择提醒天数" />
|
||||
</n-form-item>
|
||||
</n-grid-item>
|
||||
</n-grid>
|
||||
|
||||
<n-form-item label="备注">
|
||||
<n-input
|
||||
v-model:value="form.notes"
|
||||
type="textarea"
|
||||
:autosize="{ minRows: 2, maxRows: 4 }"
|
||||
placeholder="可选,记录账号、套餐或特别说明"
|
||||
/>
|
||||
</n-form-item>
|
||||
|
||||
<div class="form-footer">
|
||||
<n-space>
|
||||
<n-switch v-model:value="form.webhookEnabled" />
|
||||
<span>启用提醒通知</span>
|
||||
</n-space>
|
||||
<n-space wrap>
|
||||
<n-button @click="showAiModal = true">AI 识别</n-button>
|
||||
<n-button @click="close">取消</n-button>
|
||||
<n-button type="primary" @click="submit">保存</n-button>
|
||||
</n-space>
|
||||
</div>
|
||||
</n-form>
|
||||
|
||||
<subscription-ai-modal :show="showAiModal" @close="showAiModal = false" @apply="applyAiResult" />
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import dayjs from 'dayjs'
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { useWindowSize } from '@vueuse/core'
|
||||
import {
|
||||
NButton,
|
||||
NDatePicker,
|
||||
NEmpty,
|
||||
NForm,
|
||||
NFormItem,
|
||||
NGrid,
|
||||
NGridItem,
|
||||
NIcon,
|
||||
NInput,
|
||||
NInputNumber,
|
||||
NModal,
|
||||
NSelect,
|
||||
NSpace,
|
||||
NSpin,
|
||||
NSwitch,
|
||||
NTabPane,
|
||||
NTabs,
|
||||
useMessage
|
||||
} from 'naive-ui'
|
||||
import { CloseOutline, SearchOutline } from '@vicons/ionicons5'
|
||||
import { api } from '@/composables/api'
|
||||
import SubscriptionAiModal from '@/components/SubscriptionAiModal.vue'
|
||||
import { buildCurrencyOptions } from '@/utils/currency'
|
||||
import type { AiRecognitionResult, Category, LogoSearchResult, Subscription } from '@/types/api'
|
||||
|
||||
const LOGO_TAB_WEB = 'web'
|
||||
const LOGO_TAB_LIBRARY = 'library'
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
model?: Subscription | null
|
||||
categories: Category[]
|
||||
currencies?: string[]
|
||||
defaultNotifyDays?: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
submit: [payload: Record<string, unknown>, editingId?: string]
|
||||
}>()
|
||||
|
||||
const { width } = useWindowSize()
|
||||
const message = useMessage()
|
||||
const showAiModal = ref(false)
|
||||
const showLogoPanel = ref(false)
|
||||
const logoPanelTab = ref<string>(LOGO_TAB_WEB)
|
||||
const searchingLogoCandidates = ref(false)
|
||||
const loadingLocalLogoLibrary = ref(false)
|
||||
const logoCandidates = ref<LogoSearchResult[]>([])
|
||||
const localLogoLibrary = ref<LogoSearchResult[]>([])
|
||||
const logoFileInputRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
const layoutCols = computed(() => (width.value < 700 ? 1 : 2))
|
||||
const moneyCols = computed(() => (width.value < 900 ? 2 : 4))
|
||||
const dateCols = computed(() => (width.value < 900 ? 1 : 3))
|
||||
|
||||
const intervalOptions = [
|
||||
{ label: '天', value: 'day' },
|
||||
{ label: '周', value: 'week' },
|
||||
{ label: '月', value: 'month' },
|
||||
{ label: '季', value: 'quarter' },
|
||||
{ label: '年', value: 'year' }
|
||||
]
|
||||
|
||||
const frequencyOptions = Array.from({ length: 12 }, (_, index) => ({
|
||||
label: `${index + 1}`,
|
||||
value: index + 1
|
||||
}))
|
||||
|
||||
const notifyDayOptions = [0, 1, 3, 5, 7, 10, 14, 30].map((day) => ({
|
||||
label: day === 0 ? '到期当天' : `提前 ${day} 天`,
|
||||
value: day
|
||||
}))
|
||||
|
||||
const categoryOptions = computed(() =>
|
||||
props.categories.map((item) => ({
|
||||
label: item.name,
|
||||
value: item.id
|
||||
}))
|
||||
)
|
||||
|
||||
const currencyOptions = computed(() =>
|
||||
buildCurrencyOptions(props.currencies?.length ? props.currencies : ['CNY', 'USD', 'EUR', 'GBP', 'JPY', 'HKD'])
|
||||
)
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
categoryId: null as string | null,
|
||||
description: '',
|
||||
amount: null as number | null,
|
||||
currency: 'CNY',
|
||||
billingIntervalCount: 1,
|
||||
billingIntervalUnit: 'month',
|
||||
startDateTs: dayjs().valueOf(),
|
||||
nextRenewalDateTs: dayjs().add(1, 'month').valueOf(),
|
||||
notifyDaysBefore: props.defaultNotifyDays ?? 3,
|
||||
webhookEnabled: true,
|
||||
notes: '',
|
||||
websiteUrl: '',
|
||||
logoUrl: '',
|
||||
logoSource: ''
|
||||
})
|
||||
|
||||
const resolvedLogoUrl = computed(() => (form.logoUrl ? buildAbsoluteLogoUrl(form.logoUrl) : ''))
|
||||
|
||||
watch(
|
||||
() => props.model,
|
||||
(model) => {
|
||||
if (!model) {
|
||||
resetForm()
|
||||
return
|
||||
}
|
||||
|
||||
form.name = model.name
|
||||
form.categoryId = model.categoryId ?? null
|
||||
form.description = model.description
|
||||
form.amount = model.amount
|
||||
form.currency = model.currency
|
||||
form.billingIntervalCount = model.billingIntervalCount
|
||||
form.billingIntervalUnit = model.billingIntervalUnit
|
||||
form.startDateTs = dayjs(model.startDate).valueOf()
|
||||
form.nextRenewalDateTs = dayjs(model.nextRenewalDate).valueOf()
|
||||
form.notifyDaysBefore = model.notifyDaysBefore
|
||||
form.webhookEnabled = model.webhookEnabled
|
||||
form.notes = model.notes
|
||||
form.websiteUrl = model.websiteUrl ?? ''
|
||||
form.logoUrl = model.logoUrl ?? ''
|
||||
form.logoSource = model.logoSource ?? ''
|
||||
logoCandidates.value = []
|
||||
showLogoPanel.value = false
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.defaultNotifyDays,
|
||||
(value) => {
|
||||
if (!props.model) {
|
||||
form.notifyDaysBefore = value ?? 3
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(value) => {
|
||||
if (!value) {
|
||||
showLogoPanel.value = false
|
||||
searchingLogoCandidates.value = false
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
function resetForm() {
|
||||
form.name = ''
|
||||
form.categoryId = null
|
||||
form.description = ''
|
||||
form.amount = null
|
||||
form.currency = 'CNY'
|
||||
form.billingIntervalCount = 1
|
||||
form.billingIntervalUnit = 'month'
|
||||
form.startDateTs = dayjs().valueOf()
|
||||
form.nextRenewalDateTs = dayjs().add(1, 'month').valueOf()
|
||||
form.notifyDaysBefore = props.defaultNotifyDays ?? 3
|
||||
form.webhookEnabled = true
|
||||
form.notes = ''
|
||||
form.websiteUrl = ''
|
||||
form.logoUrl = ''
|
||||
form.logoSource = ''
|
||||
logoCandidates.value = []
|
||||
showLogoPanel.value = false
|
||||
}
|
||||
|
||||
async function openLogoPanel() {
|
||||
showLogoPanel.value = true
|
||||
await loadLocalLogoLibrary()
|
||||
|
||||
if (!form.name.trim() && !form.websiteUrl.trim()) {
|
||||
logoPanelTab.value = LOGO_TAB_LIBRARY
|
||||
message.info('未填写名称或官网时,先为你展示本地已保存 Logo。')
|
||||
return
|
||||
}
|
||||
|
||||
logoPanelTab.value = LOGO_TAB_WEB
|
||||
await searchLogos()
|
||||
}
|
||||
|
||||
async function searchLogos() {
|
||||
if (!form.name.trim() && !form.websiteUrl.trim()) {
|
||||
logoCandidates.value = []
|
||||
return
|
||||
}
|
||||
|
||||
searchingLogoCandidates.value = true
|
||||
try {
|
||||
logoCandidates.value = await api.searchSubscriptionLogos({
|
||||
name: form.name.trim() || 'subscription',
|
||||
websiteUrl: form.websiteUrl.trim() || undefined,
|
||||
categoryName: props.categories.find((item) => item.id === form.categoryId)?.name
|
||||
})
|
||||
|
||||
if (!logoCandidates.value.length) {
|
||||
message.warning('没有找到可用 Logo')
|
||||
}
|
||||
} catch (error) {
|
||||
logoCandidates.value = []
|
||||
message.error(error instanceof Error ? error.message : 'Logo 搜索失败')
|
||||
} finally {
|
||||
searchingLogoCandidates.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadLocalLogoLibrary(force = false) {
|
||||
if (loadingLocalLogoLibrary.value) return
|
||||
if (localLogoLibrary.value.length && !force) return
|
||||
|
||||
loadingLocalLogoLibrary.value = true
|
||||
try {
|
||||
localLogoLibrary.value = await api.getSubscriptionLogoLibrary()
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '本地 Logo 加载失败')
|
||||
} finally {
|
||||
loadingLocalLogoLibrary.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function applyRemoteLogoCandidate(item: LogoSearchResult) {
|
||||
try {
|
||||
const imported = await api.importSubscriptionLogo({
|
||||
logoUrl: item.logoUrl,
|
||||
source: item.source
|
||||
})
|
||||
|
||||
form.logoUrl = imported.logoUrl
|
||||
form.logoSource = imported.logoSource
|
||||
if (item.websiteUrl && !form.websiteUrl) {
|
||||
form.websiteUrl = item.websiteUrl
|
||||
}
|
||||
showLogoPanel.value = false
|
||||
await loadLocalLogoLibrary(true)
|
||||
message.success('已保存到本地并应用')
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : 'Logo 保存失败')
|
||||
}
|
||||
}
|
||||
|
||||
function applyLocalLogoCandidate(item: LogoSearchResult) {
|
||||
form.logoUrl = item.logoUrl
|
||||
form.logoSource = item.source
|
||||
showLogoPanel.value = false
|
||||
message.success('已从本地库复用')
|
||||
}
|
||||
|
||||
async function deleteLocalLogo(item: LogoSearchResult) {
|
||||
if (!item.filename) return
|
||||
try {
|
||||
await api.deleteSubscriptionLogoFromLibrary(item.filename)
|
||||
localLogoLibrary.value = localLogoLibrary.value.filter((entry) => entry.logoUrl !== item.logoUrl)
|
||||
if (form.logoUrl === item.logoUrl) {
|
||||
form.logoUrl = ''
|
||||
form.logoSource = ''
|
||||
}
|
||||
message.success('本地 Logo 文件已删除')
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '本地 Logo 删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
function pickLogoFile() {
|
||||
logoFileInputRef.value?.click()
|
||||
}
|
||||
|
||||
async function handleLogoFileChange(event: Event) {
|
||||
const file = (event.target as HTMLInputElement).files?.[0]
|
||||
if (!file) return
|
||||
|
||||
try {
|
||||
const base64 = await readFileAsBase64(file)
|
||||
const uploaded = await api.uploadSubscriptionLogo({
|
||||
filename: file.name,
|
||||
contentType: file.type || 'image/png',
|
||||
base64
|
||||
})
|
||||
|
||||
form.logoUrl = uploaded.logoUrl
|
||||
form.logoSource = uploaded.logoSource
|
||||
showLogoPanel.value = false
|
||||
await loadLocalLogoLibrary(true)
|
||||
message.success('Logo 已上传')
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : 'Logo 上传失败')
|
||||
} finally {
|
||||
;(event.target as HTMLInputElement).value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function buildAbsoluteLogoUrl(url: string) {
|
||||
if (!url) return ''
|
||||
if (url.startsWith('http')) return url
|
||||
const base = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:3001/api/v1'
|
||||
return new URL(url, base.replace(/\/api\/v1$/, '')).toString()
|
||||
}
|
||||
|
||||
function readFileAsBase64(file: File) {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
const raw = String(reader.result ?? '')
|
||||
resolve(raw.includes(',') ? raw.split(',')[1] : raw)
|
||||
}
|
||||
reader.onerror = () => reject(reader.error)
|
||||
reader.readAsDataURL(file)
|
||||
})
|
||||
}
|
||||
|
||||
function formatLogoSource(source: string) {
|
||||
switch (source) {
|
||||
case 'duckduckgo':
|
||||
return 'DuckDuckGo'
|
||||
case 'brave':
|
||||
return 'Brave'
|
||||
case 'apple-touch-icon':
|
||||
return 'Apple Touch Icon'
|
||||
case 'html-icon':
|
||||
return '站点图标'
|
||||
case 'favicon':
|
||||
return 'Favicon'
|
||||
case 'google-favicon':
|
||||
return 'Google Favicon'
|
||||
case 'clearbit':
|
||||
return 'Clearbit'
|
||||
case 'icon-horse':
|
||||
return 'Icon Horse'
|
||||
case 'local-file':
|
||||
return '本地文件'
|
||||
case 'upload':
|
||||
return '本地上传'
|
||||
default:
|
||||
return source
|
||||
}
|
||||
}
|
||||
|
||||
function clearLogo() {
|
||||
form.logoUrl = ''
|
||||
form.logoSource = ''
|
||||
logoCandidates.value = []
|
||||
showLogoPanel.value = false
|
||||
}
|
||||
|
||||
function applyAiResult(result: AiRecognitionResult) {
|
||||
if (result.name) form.name = result.name
|
||||
if (result.description) form.description = result.description
|
||||
if (result.amount !== undefined) form.amount = result.amount
|
||||
if (result.currency) form.currency = result.currency
|
||||
if (result.billingIntervalCount) form.billingIntervalCount = result.billingIntervalCount
|
||||
if (result.billingIntervalUnit) form.billingIntervalUnit = result.billingIntervalUnit
|
||||
if (result.startDate) form.startDateTs = dayjs(result.startDate).valueOf()
|
||||
if (result.nextRenewalDate) form.nextRenewalDateTs = dayjs(result.nextRenewalDate).valueOf()
|
||||
if (result.notifyDaysBefore !== undefined) form.notifyDaysBefore = result.notifyDaysBefore
|
||||
if (result.websiteUrl) form.websiteUrl = result.websiteUrl
|
||||
if (result.notes) form.notes = result.notes
|
||||
}
|
||||
|
||||
function submit() {
|
||||
emit(
|
||||
'submit',
|
||||
{
|
||||
name: form.name,
|
||||
categoryId: form.categoryId,
|
||||
description: form.description,
|
||||
amount: Number(form.amount ?? 0),
|
||||
currency: form.currency,
|
||||
billingIntervalCount: Number(form.billingIntervalCount),
|
||||
billingIntervalUnit: form.billingIntervalUnit,
|
||||
startDate: dayjs(form.startDateTs).format('YYYY-MM-DD'),
|
||||
nextRenewalDate: dayjs(form.nextRenewalDateTs).format('YYYY-MM-DD'),
|
||||
notifyDaysBefore: Number(form.notifyDaysBefore),
|
||||
webhookEnabled: form.webhookEnabled,
|
||||
notes: form.notes,
|
||||
websiteUrl: form.websiteUrl || null,
|
||||
logoUrl: form.logoUrl || null,
|
||||
logoSource: form.logoSource || null
|
||||
},
|
||||
props.model?.id
|
||||
)
|
||||
}
|
||||
|
||||
function close() {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
function handleUpdateShow(value: boolean) {
|
||||
if (!value) {
|
||||
close()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.hidden-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.name-logo-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.name-logo-row__name {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.logo-dock {
|
||||
position: relative;
|
||||
flex: 0 0 148px;
|
||||
padding-top: 30px;
|
||||
}
|
||||
|
||||
.logo-dock__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.logo-dock__preview-wrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.logo-dock__preview {
|
||||
width: 104px;
|
||||
height: 42px;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid #dbe2ea;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%);
|
||||
box-shadow: 0 6px 14px rgba(15, 23, 42, 0.08);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.logo-dock__preview:hover {
|
||||
border-color: #94a3b8;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.logo-dock__image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.logo-dock__placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(135deg, #eff6ff 0%, #eef2ff 100%);
|
||||
color: #1d4ed8;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.logo-dock__clear {
|
||||
position: absolute;
|
||||
top: -7px;
|
||||
right: -7px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 1px solid #dbe2ea;
|
||||
border-radius: 999px;
|
||||
background: #fff;
|
||||
color: #64748b;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 4px 10px rgba(15, 23, 42, 0.12);
|
||||
}
|
||||
|
||||
.logo-panel {
|
||||
position: absolute;
|
||||
top: 78px;
|
||||
right: 0;
|
||||
width: min(320px, calc(100vw - 48px));
|
||||
max-height: 440px;
|
||||
padding: 10px;
|
||||
border: 1px solid #dbe2ea;
|
||||
border-radius: 16px;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.16);
|
||||
z-index: 20;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.logo-panel__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 2px 2px 10px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.logo-panel__close {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.logo-panel__tabs {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.logo-panel__state {
|
||||
min-height: 180px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.logo-panel__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
max-height: 290px;
|
||||
overflow-y: auto;
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
.logo-panel__item-wrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.logo-panel__item {
|
||||
width: 100%;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 14px;
|
||||
background: #fff;
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s ease, transform 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.logo-panel__delete {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
z-index: 1;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
color: #64748b;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.logo-panel__item:hover {
|
||||
border-color: #93c5fd;
|
||||
box-shadow: 0 10px 24px rgba(59, 130, 246, 0.12);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.logo-panel__item-image {
|
||||
width: 100%;
|
||||
height: 88px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.logo-panel__item-label {
|
||||
width: 100%;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #0f172a;
|
||||
text-align: center;
|
||||
line-height: 1.4;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.logo-panel__item-meta {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo-panel__item-related {
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
text-align: center;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.logo-panel__empty {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.form-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.name-logo-row {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.logo-dock {
|
||||
width: 100%;
|
||||
flex: none;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.logo-dock__row {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.logo-panel {
|
||||
position: static;
|
||||
width: 100%;
|
||||
max-height: none;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.form-footer {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.form-footer :deep(.n-space) {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
119
apps/web/src/components/WebhookFormModal.vue
Normal file
119
apps/web/src/components/WebhookFormModal.vue
Normal file
@@ -0,0 +1,119 @@
|
||||
<template>
|
||||
<n-modal
|
||||
:show="show"
|
||||
preset="card"
|
||||
title="Webhook Endpoint"
|
||||
style="width: 560px"
|
||||
@mask-click="close"
|
||||
@update:show="handleUpdateShow"
|
||||
>
|
||||
<n-form :model="form" label-placement="left" label-width="90">
|
||||
<n-form-item label="名称">
|
||||
<n-input v-model:value="form.name" />
|
||||
</n-form-item>
|
||||
<n-form-item label="URL">
|
||||
<n-input v-model:value="form.url" placeholder="https://example.com/hook" />
|
||||
</n-form-item>
|
||||
<n-form-item label="Secret">
|
||||
<n-input v-model:value="form.secret" type="password" show-password-on="click" />
|
||||
</n-form-item>
|
||||
<n-form-item label="事件">
|
||||
<n-checkbox-group v-model:value="form.events">
|
||||
<n-space vertical>
|
||||
<n-checkbox value="subscription.reminder_due">subscription.reminder_due</n-checkbox>
|
||||
<n-checkbox value="subscription.overdue">subscription.overdue</n-checkbox>
|
||||
<n-checkbox value="subscription.renewed">subscription.renewed</n-checkbox>
|
||||
<n-checkbox value="exchange-rate.stale">exchange-rate.stale</n-checkbox>
|
||||
</n-space>
|
||||
</n-checkbox-group>
|
||||
</n-form-item>
|
||||
<n-form-item label="启用">
|
||||
<n-switch v-model:value="form.enabled" />
|
||||
</n-form-item>
|
||||
|
||||
<n-space justify="end">
|
||||
<n-button @click="close">取消</n-button>
|
||||
<n-button type="primary" @click="onSubmit">保存</n-button>
|
||||
</n-space>
|
||||
</n-form>
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, watch } from 'vue'
|
||||
import {
|
||||
NButton,
|
||||
NCheckbox,
|
||||
NCheckboxGroup,
|
||||
NForm,
|
||||
NFormItem,
|
||||
NInput,
|
||||
NModal,
|
||||
NSpace,
|
||||
NSwitch
|
||||
} from 'naive-ui'
|
||||
import type { WebhookEndpoint } from '@/types/api'
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
model?: WebhookEndpoint | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
submit: [payload: Record<string, unknown>, id?: string]
|
||||
}>()
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
url: '',
|
||||
secret: '',
|
||||
enabled: true,
|
||||
events: ['subscription.reminder_due'] as string[]
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.model,
|
||||
(model) => {
|
||||
if (!model) {
|
||||
form.name = ''
|
||||
form.url = ''
|
||||
form.secret = ''
|
||||
form.enabled = true
|
||||
form.events = ['subscription.reminder_due']
|
||||
return
|
||||
}
|
||||
|
||||
form.name = model.name
|
||||
form.url = model.url
|
||||
form.secret = model.secret
|
||||
form.enabled = model.enabled
|
||||
form.events = model.eventsJson ?? []
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
function onSubmit() {
|
||||
emit(
|
||||
'submit',
|
||||
{
|
||||
name: form.name,
|
||||
url: form.url,
|
||||
secret: form.secret,
|
||||
enabled: form.enabled,
|
||||
events: form.events
|
||||
},
|
||||
props.model?.id
|
||||
)
|
||||
}
|
||||
|
||||
function close() {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
function handleUpdateShow(value: boolean) {
|
||||
if (!value) {
|
||||
close()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
278
apps/web/src/composables/api.ts
Normal file
278
apps/web/src/composables/api.ts
Normal file
@@ -0,0 +1,278 @@
|
||||
import axios from 'axios'
|
||||
import type {
|
||||
AiRecognitionResult,
|
||||
AuthResponse,
|
||||
AuthUserResponse,
|
||||
CalendarEvent,
|
||||
Category,
|
||||
ChangeCredentialsPayload,
|
||||
ExchangeRateSnapshot,
|
||||
LoginPayload,
|
||||
LogoSearchResult,
|
||||
NotificationWebhookSettings,
|
||||
Settings,
|
||||
StatisticsOverview,
|
||||
Subscription,
|
||||
SubscriptionDetail,
|
||||
WebhookEndpoint
|
||||
} from '@/types/api'
|
||||
import { clearAuthSession, getStoredToken } from '@/utils/auth-storage'
|
||||
|
||||
const client = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:3001/api/v1',
|
||||
timeout: 30000
|
||||
})
|
||||
|
||||
const LOGO_REQUEST_TIMEOUT_MS = 60000
|
||||
|
||||
client.interceptors.request.use((request) => {
|
||||
const token = getStoredToken()
|
||||
if (token) {
|
||||
request.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return request
|
||||
})
|
||||
|
||||
client.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error?.response?.status === 401) {
|
||||
clearAuthSession()
|
||||
if (typeof window !== 'undefined' && window.location.pathname !== '/login') {
|
||||
window.location.href = `/login?redirect=${encodeURIComponent(window.location.pathname)}`
|
||||
}
|
||||
}
|
||||
|
||||
const message = error?.response?.data?.error?.message
|
||||
return Promise.reject(new Error(message || error.message || '请求失败'))
|
||||
}
|
||||
)
|
||||
|
||||
type Envelope<T> = { data: T }
|
||||
|
||||
function unwrap<T>(res: { data: Envelope<T> }): T {
|
||||
return res.data.data
|
||||
}
|
||||
|
||||
export const api = {
|
||||
async login(username: string, password: string) {
|
||||
return unwrap<AuthResponse>((await client.post('/auth/login', { username, password } satisfies LoginPayload)) as {
|
||||
data: Envelope<AuthResponse>
|
||||
})
|
||||
},
|
||||
|
||||
async getCurrentUser() {
|
||||
return unwrap<AuthUserResponse>((await client.get('/auth/me')) as { data: Envelope<AuthUserResponse> })
|
||||
},
|
||||
|
||||
async changeCredentials(payload: ChangeCredentialsPayload) {
|
||||
return unwrap<AuthResponse>((await client.post('/auth/change-credentials', payload)) as {
|
||||
data: Envelope<AuthResponse>
|
||||
})
|
||||
},
|
||||
|
||||
async getSubscriptions(params?: { q?: string; status?: string; categoryId?: string }) {
|
||||
return unwrap<Subscription[]>((await client.get('/subscriptions', { params })) as { data: Envelope<Subscription[]> })
|
||||
},
|
||||
|
||||
async getSubscription(id: string) {
|
||||
return unwrap<SubscriptionDetail>((await client.get(`/subscriptions/${id}`)) as { data: Envelope<SubscriptionDetail> })
|
||||
},
|
||||
|
||||
async createSubscription(payload: Record<string, unknown>) {
|
||||
return unwrap<Subscription>((await client.post('/subscriptions', payload)) as { data: Envelope<Subscription> })
|
||||
},
|
||||
|
||||
async searchSubscriptionLogos(payload: { name: string; websiteUrl?: string; categoryName?: string }) {
|
||||
return unwrap<LogoSearchResult[]>((await client.post('/subscriptions/logo/search', payload, { timeout: LOGO_REQUEST_TIMEOUT_MS })) as {
|
||||
data: Envelope<LogoSearchResult[]>
|
||||
})
|
||||
},
|
||||
|
||||
async getSubscriptionLogoLibrary() {
|
||||
return unwrap<LogoSearchResult[]>((await client.get('/subscriptions/logo/library')) as {
|
||||
data: Envelope<LogoSearchResult[]>
|
||||
})
|
||||
},
|
||||
|
||||
async deleteSubscriptionLogoFromLibrary(filename: string) {
|
||||
return unwrap<{ filename: string; logoUrl: string; deleted: boolean }>(
|
||||
(await client.delete(`/subscriptions/logo/library/${encodeURIComponent(filename)}`)) as {
|
||||
data: Envelope<{ filename: string; logoUrl: string; deleted: boolean }>
|
||||
}
|
||||
)
|
||||
},
|
||||
|
||||
async uploadSubscriptionLogo(payload: { filename: string; contentType: string; base64: string }) {
|
||||
return unwrap<{ logoUrl: string; logoSource: string }>((await client.post('/subscriptions/logo/upload', payload)) as {
|
||||
data: Envelope<{ logoUrl: string; logoSource: string }>
|
||||
})
|
||||
},
|
||||
|
||||
async importSubscriptionLogo(payload: { logoUrl: string; source?: string }) {
|
||||
return unwrap<{ logoUrl: string; logoSource: string }>(
|
||||
(await client.post('/subscriptions/logo/import', payload, { timeout: LOGO_REQUEST_TIMEOUT_MS })) as {
|
||||
data: Envelope<{ logoUrl: string; logoSource: string }>
|
||||
}
|
||||
)
|
||||
},
|
||||
|
||||
async updateSubscription(id: string, payload: Record<string, unknown>) {
|
||||
return unwrap<Subscription>((await client.patch(`/subscriptions/${id}`, payload)) as { data: Envelope<Subscription> })
|
||||
},
|
||||
|
||||
async reorderSubscriptions(ids: string[]) {
|
||||
return unwrap<{ success: boolean }>((await client.post('/subscriptions/reorder', { ids })) as {
|
||||
data: Envelope<{ success: boolean }>
|
||||
})
|
||||
},
|
||||
|
||||
async renewSubscription(id: string, payload: Record<string, unknown> = {}) {
|
||||
return unwrap<{ subscription: Subscription }>(
|
||||
(await client.post(`/subscriptions/${id}/renew`, payload)) as { data: Envelope<{ subscription: Subscription }> }
|
||||
)
|
||||
},
|
||||
|
||||
async pauseSubscription(id: string) {
|
||||
return unwrap<Subscription>((await client.post(`/subscriptions/${id}/pause`)) as { data: Envelope<Subscription> })
|
||||
},
|
||||
|
||||
async cancelSubscription(id: string) {
|
||||
return unwrap<Subscription>((await client.post(`/subscriptions/${id}/cancel`)) as { data: Envelope<Subscription> })
|
||||
},
|
||||
|
||||
async deleteSubscription(id: string) {
|
||||
return unwrap<{ id: string; deleted: boolean }>(
|
||||
(await client.delete(`/subscriptions/${id}`)) as { data: Envelope<{ id: string; deleted: boolean }> }
|
||||
)
|
||||
},
|
||||
|
||||
async recognizeSubscriptionByAi(payload: {
|
||||
text?: string
|
||||
imageBase64?: string
|
||||
filename?: string
|
||||
mimeType?: string
|
||||
}) {
|
||||
return unwrap<AiRecognitionResult>((await client.post('/ai/recognize-subscription', payload)) as {
|
||||
data: Envelope<AiRecognitionResult>
|
||||
})
|
||||
},
|
||||
|
||||
async testAiConfiguration() {
|
||||
return unwrap<{ success: boolean; providerName: string; model: string; response: string }>(
|
||||
(await client.post('/ai/test')) as {
|
||||
data: Envelope<{ success: boolean; providerName: string; model: string; response: string }>
|
||||
}
|
||||
)
|
||||
},
|
||||
|
||||
async testAiConfigurationWithPayload(payload: Settings['aiConfig']) {
|
||||
return unwrap<{ success: boolean; providerName: string; model: string; response: string }>(
|
||||
(await client.post('/ai/test', payload)) as {
|
||||
data: Envelope<{ success: boolean; providerName: string; model: string; response: string }>
|
||||
}
|
||||
)
|
||||
},
|
||||
|
||||
async getCategories() {
|
||||
return unwrap<Category[]>((await client.get('/categories')) as { data: Envelope<Category[]> })
|
||||
},
|
||||
|
||||
async createCategory(payload: Record<string, unknown>) {
|
||||
return unwrap<Category>((await client.post('/categories', payload)) as { data: Envelope<Category> })
|
||||
},
|
||||
|
||||
async updateCategory(id: string, payload: Record<string, unknown>) {
|
||||
return unwrap<Category>((await client.patch(`/categories/${id}`, payload)) as { data: Envelope<Category> })
|
||||
},
|
||||
|
||||
async deleteCategory(id: string) {
|
||||
return unwrap<{ id: string; deleted: boolean }>(
|
||||
(await client.delete(`/categories/${id}`)) as { data: Envelope<{ id: string; deleted: boolean }> }
|
||||
)
|
||||
},
|
||||
|
||||
async getStatisticsOverview() {
|
||||
return unwrap<StatisticsOverview>((await client.get('/statistics/overview')) as { data: Envelope<StatisticsOverview> })
|
||||
},
|
||||
|
||||
async getCalendarEvents(params?: { start?: string; end?: string }) {
|
||||
return unwrap<CalendarEvent[]>((await client.get('/calendar/events', { params })) as { data: Envelope<CalendarEvent[]> })
|
||||
},
|
||||
|
||||
async getSettings() {
|
||||
return unwrap<Settings>((await client.get('/settings')) as { data: Envelope<Settings> })
|
||||
},
|
||||
|
||||
async updateSettings(payload: Partial<Settings>) {
|
||||
return unwrap<Settings>((await client.patch('/settings', payload)) as { data: Envelope<Settings> })
|
||||
},
|
||||
|
||||
async getExchangeRateSnapshot() {
|
||||
return unwrap<ExchangeRateSnapshot>((await client.get('/exchange-rates/latest')) as { data: Envelope<ExchangeRateSnapshot> })
|
||||
},
|
||||
|
||||
async refreshExchangeRates() {
|
||||
return unwrap<ExchangeRateSnapshot>((await client.post('/exchange-rates/refresh')) as { data: Envelope<ExchangeRateSnapshot> })
|
||||
},
|
||||
|
||||
async testEmailNotification() {
|
||||
return unwrap<{ success: boolean }>((await client.post('/notifications/test/email')) as {
|
||||
data: Envelope<{ success: boolean }>
|
||||
})
|
||||
},
|
||||
|
||||
async testEmailNotificationWithPayload(payload: Settings['emailConfig']) {
|
||||
return unwrap<{ success: boolean }>((await client.post('/notifications/test/email', payload)) as {
|
||||
data: Envelope<{ success: boolean }>
|
||||
})
|
||||
},
|
||||
|
||||
async testPushplusNotification() {
|
||||
return unwrap<{ success: boolean }>((await client.post('/notifications/test/pushplus')) as {
|
||||
data: Envelope<{ success: boolean }>
|
||||
})
|
||||
},
|
||||
|
||||
async testPushplusNotificationWithPayload(payload: Settings['pushplusConfig']) {
|
||||
return unwrap<{ success: boolean }>((await client.post('/notifications/test/pushplus', payload)) as {
|
||||
data: Envelope<{ success: boolean }>
|
||||
})
|
||||
},
|
||||
|
||||
async getNotificationWebhook() {
|
||||
return unwrap<NotificationWebhookSettings>((await client.get('/notifications/webhook')) as {
|
||||
data: Envelope<NotificationWebhookSettings>
|
||||
})
|
||||
},
|
||||
|
||||
async updateNotificationWebhook(payload: { url: string; secret: string; enabled: boolean }) {
|
||||
return unwrap<NotificationWebhookSettings>((await client.put('/notifications/webhook', payload)) as {
|
||||
data: Envelope<NotificationWebhookSettings>
|
||||
})
|
||||
},
|
||||
|
||||
async testWebhookNotification() {
|
||||
return unwrap<{ success: boolean }>((await client.post('/notifications/test/webhook')) as {
|
||||
data: Envelope<{ success: boolean }>
|
||||
})
|
||||
},
|
||||
|
||||
async testWebhookNotificationWithPayload(payload: { url: string; secret: string; enabled: boolean }) {
|
||||
return unwrap<{ success: boolean }>((await client.post('/notifications/test/webhook', payload)) as {
|
||||
data: Envelope<{ success: boolean }>
|
||||
})
|
||||
},
|
||||
|
||||
async getWebhookEndpoints() {
|
||||
return unwrap<WebhookEndpoint[]>((await client.get('/webhooks')) as { data: Envelope<WebhookEndpoint[]> })
|
||||
},
|
||||
|
||||
async createWebhookEndpoint(payload: Record<string, unknown>) {
|
||||
return unwrap<WebhookEndpoint>((await client.post('/webhooks', payload)) as { data: Envelope<WebhookEndpoint> })
|
||||
},
|
||||
|
||||
async updateWebhookEndpoint(id: string, payload: Record<string, unknown>) {
|
||||
return unwrap<WebhookEndpoint>((await client.patch(`/webhooks/${id}`, payload)) as { data: Envelope<WebhookEndpoint> })
|
||||
}
|
||||
}
|
||||
168
apps/web/src/data/currency-names.zh-CN.json
Normal file
168
apps/web/src/data/currency-names.zh-CN.json
Normal file
@@ -0,0 +1,168 @@
|
||||
{
|
||||
"AED": "阿联酋迪拉姆",
|
||||
"AFN": "阿富汗尼",
|
||||
"ALL": "阿尔巴尼亚列克",
|
||||
"AMD": "亚美尼亚德拉姆",
|
||||
"ANG": "荷属安的列斯盾",
|
||||
"AOA": "安哥拉宽扎",
|
||||
"ARS": "阿根廷比索",
|
||||
"AUD": "澳大利亚元",
|
||||
"AWG": "阿鲁巴弗罗林",
|
||||
"AZN": "阿塞拜疆马纳特",
|
||||
"BAM": "波黑可兑换马克",
|
||||
"BBD": "巴巴多斯元",
|
||||
"BDT": "孟加拉塔卡",
|
||||
"BGN": "保加利亚列弗",
|
||||
"BHD": "巴林第纳尔",
|
||||
"BIF": "布隆迪法郎",
|
||||
"BMD": "百慕大元",
|
||||
"BND": "文莱元",
|
||||
"BOB": "玻利维亚诺",
|
||||
"BRL": "巴西雷亚尔",
|
||||
"BSD": "巴哈马元",
|
||||
"BTN": "不丹努尔特鲁姆",
|
||||
"BWP": "博茨瓦纳普拉",
|
||||
"BYN": "白俄罗斯卢布",
|
||||
"BZD": "伯利兹元",
|
||||
"CAD": "加拿大元",
|
||||
"CDF": "刚果法郎",
|
||||
"CHF": "瑞士法郎",
|
||||
"CLF": "智利记账单位UF",
|
||||
"CLP": "智利比索",
|
||||
"CNH": "离岸人民币",
|
||||
"CNY": "人民币",
|
||||
"COP": "哥伦比亚比索",
|
||||
"CRC": "哥斯达黎加科朗",
|
||||
"CUP": "古巴比索",
|
||||
"CVE": "佛得角埃斯库多",
|
||||
"CZK": "捷克克朗",
|
||||
"DJF": "吉布提法郎",
|
||||
"DKK": "丹麦克朗",
|
||||
"DOP": "多米尼加比索",
|
||||
"DZD": "阿尔及利亚第纳尔",
|
||||
"EGP": "埃及镑",
|
||||
"ERN": "厄立特里亚纳克法",
|
||||
"ETB": "埃塞俄比亚比尔",
|
||||
"EUR": "欧元",
|
||||
"FJD": "斐济元",
|
||||
"FKP": "福克兰群岛镑",
|
||||
"FOK": "法罗克朗",
|
||||
"GBP": "英镑",
|
||||
"GEL": "格鲁吉亚拉里",
|
||||
"GGP": "根西镑",
|
||||
"GHS": "加纳塞地",
|
||||
"GIP": "直布罗陀镑",
|
||||
"GMD": "冈比亚达拉西",
|
||||
"GNF": "几内亚法郎",
|
||||
"GTQ": "危地马拉格查尔",
|
||||
"GYD": "圭亚那元",
|
||||
"HKD": "港币",
|
||||
"HNL": "洪都拉斯伦皮拉",
|
||||
"HRK": "克罗地亚库纳",
|
||||
"HTG": "海地古德",
|
||||
"HUF": "匈牙利福林",
|
||||
"IDR": "印尼盾",
|
||||
"ILS": "以色列新谢克尔",
|
||||
"IMP": "马恩岛镑",
|
||||
"INR": "印度卢比",
|
||||
"IQD": "伊拉克第纳尔",
|
||||
"IRR": "伊朗里亚尔",
|
||||
"ISK": "冰岛克朗",
|
||||
"JEP": "泽西镑",
|
||||
"JMD": "牙买加元",
|
||||
"JOD": "约旦第纳尔",
|
||||
"JPY": "日元",
|
||||
"KES": "肯尼亚先令",
|
||||
"KGS": "吉尔吉斯斯坦索姆",
|
||||
"KHR": "柬埔寨瑞尔",
|
||||
"KID": "基里巴斯元",
|
||||
"KMF": "科摩罗法郎",
|
||||
"KRW": "韩元",
|
||||
"KWD": "科威特第纳尔",
|
||||
"KYD": "开曼群岛元",
|
||||
"KZT": "哈萨克斯坦坚戈",
|
||||
"LAK": "老挝基普",
|
||||
"LBP": "黎巴嫩镑",
|
||||
"LKR": "斯里兰卡卢比",
|
||||
"LRD": "利比里亚元",
|
||||
"LSL": "莱索托洛蒂",
|
||||
"LYD": "利比亚第纳尔",
|
||||
"MAD": "摩洛哥迪拉姆",
|
||||
"MDL": "摩尔多瓦列伊",
|
||||
"MGA": "马达加斯加阿里亚里",
|
||||
"MKD": "北马其顿第纳尔",
|
||||
"MMK": "缅甸缅元",
|
||||
"MNT": "蒙古图格里克",
|
||||
"MOP": "澳门元",
|
||||
"MRU": "毛里塔尼亚乌吉亚",
|
||||
"MUR": "毛里求斯卢比",
|
||||
"MVR": "马尔代夫拉菲亚",
|
||||
"MWK": "马拉维克瓦查",
|
||||
"MXN": "墨西哥比索",
|
||||
"MYR": "马来西亚林吉特",
|
||||
"MZN": "莫桑比克梅蒂卡尔",
|
||||
"NAD": "纳米比亚元",
|
||||
"NGN": "尼日利亚奈拉",
|
||||
"NIO": "尼加拉瓜科多巴",
|
||||
"NOK": "挪威克朗",
|
||||
"NPR": "尼泊尔卢比",
|
||||
"NZD": "新西兰元",
|
||||
"OMR": "阿曼里亚尔",
|
||||
"PAB": "巴拿马巴波亚",
|
||||
"PEN": "秘鲁索尔",
|
||||
"PGK": "巴布亚新几内亚基那",
|
||||
"PHP": "菲律宾比索",
|
||||
"PKR": "巴基斯坦卢比",
|
||||
"PLN": "波兰兹罗提",
|
||||
"PYG": "巴拉圭瓜拉尼",
|
||||
"QAR": "卡塔尔里亚尔",
|
||||
"RON": "罗马尼亚列伊",
|
||||
"RSD": "塞尔维亚第纳尔",
|
||||
"RUB": "俄罗斯卢布",
|
||||
"RWF": "卢旺达法郎",
|
||||
"SAR": "沙特里亚尔",
|
||||
"SBD": "所罗门群岛元",
|
||||
"SCR": "塞舌尔卢比",
|
||||
"SDG": "苏丹镑",
|
||||
"SEK": "瑞典克朗",
|
||||
"SGD": "新加坡元",
|
||||
"SHP": "圣赫勒拿镑",
|
||||
"SLE": "塞拉利昂新利昂",
|
||||
"SLL": "塞拉利昂利昂",
|
||||
"SOS": "索马里先令",
|
||||
"SRD": "苏里南元",
|
||||
"SSP": "南苏丹镑",
|
||||
"STN": "圣多美和普林西比多布拉",
|
||||
"SYP": "叙利亚镑",
|
||||
"SZL": "斯威士兰里兰吉尼",
|
||||
"THB": "泰铢",
|
||||
"TJS": "塔吉克斯坦索莫尼",
|
||||
"TMT": "土库曼斯坦马纳特",
|
||||
"TND": "突尼斯第纳尔",
|
||||
"TOP": "汤加潘加",
|
||||
"TRY": "土耳其里拉",
|
||||
"TTD": "特立尼达和多巴哥元",
|
||||
"TVD": "图瓦卢元",
|
||||
"TWD": "新台币",
|
||||
"TZS": "坦桑尼亚先令",
|
||||
"UAH": "乌克兰格里夫纳",
|
||||
"UGX": "乌干达先令",
|
||||
"USD": "美元",
|
||||
"UYU": "乌拉圭比索",
|
||||
"UZS": "乌兹别克斯坦苏姆",
|
||||
"VES": "委内瑞拉玻利瓦尔",
|
||||
"VND": "越南盾",
|
||||
"VUV": "瓦努阿图瓦图",
|
||||
"WST": "萨摩亚塔拉",
|
||||
"XAF": "中非法郎",
|
||||
"XCD": "东加勒比元",
|
||||
"XCG": "荷属加勒比盾",
|
||||
"XDR": "特别提款权",
|
||||
"XOF": "西非法郎",
|
||||
"XPF": "太平洋法郎",
|
||||
"YER": "也门里亚尔",
|
||||
"ZAR": "南非兰特",
|
||||
"ZMW": "赞比亚克瓦查",
|
||||
"ZWG": "津巴布韦金本位货币",
|
||||
"ZWL": "津巴布韦元"
|
||||
}
|
||||
5
apps/web/src/env.d.ts
vendored
Normal file
5
apps/web/src/env.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<Record<string, never>, Record<string, never>, unknown>
|
||||
export default component
|
||||
}
|
||||
20
apps/web/src/main.ts
Normal file
20
apps/web/src/main.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { createApp } from 'vue'
|
||||
import { VueQueryPlugin, QueryClient } from '@tanstack/vue-query'
|
||||
import { createPinia } from 'pinia'
|
||||
import { create, NConfigProvider, NMessageProvider } from 'naive-ui'
|
||||
import App from './App.vue'
|
||||
import { router } from './router'
|
||||
import './style.css'
|
||||
|
||||
const naive = create({
|
||||
components: [NConfigProvider, NMessageProvider]
|
||||
})
|
||||
|
||||
const app = createApp(App)
|
||||
const queryClient = new QueryClient()
|
||||
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.use(naive)
|
||||
app.use(VueQueryPlugin, { queryClient })
|
||||
app.mount('#app')
|
||||
347
apps/web/src/pages/CalendarPage.vue
Normal file
347
apps/web/src/pages/CalendarPage.vue
Normal file
@@ -0,0 +1,347 @@
|
||||
<template>
|
||||
<div>
|
||||
<page-header
|
||||
title="订阅日历"
|
||||
subtitle="查看续费日期分布,支持月视图和列表视图"
|
||||
:icon="calendarOutline"
|
||||
icon-background="linear-gradient(135deg, #8b5cf6 0%, #6366f1 100%)"
|
||||
/>
|
||||
|
||||
<n-grid :cols="summaryCols" :x-gap="12" :y-gap="12" style="margin-bottom: 12px">
|
||||
<n-grid-item>
|
||||
<stat-card label="当前月份" :value="panelMonthLabel" suffix="当前正在查看的月份" :icon="calendarClearOutline" />
|
||||
</n-grid-item>
|
||||
<n-grid-item>
|
||||
<stat-card label="本月续费数量" :value="monthEventCount" suffix="当前月份内的订阅数" :icon="notificationsOutline" />
|
||||
</n-grid-item>
|
||||
<n-grid-item>
|
||||
<stat-card
|
||||
label="本月预计支出"
|
||||
:value="`${baseCurrency} ${monthConvertedAmount.toFixed(2)}`"
|
||||
suffix="已按汇率折算"
|
||||
:icon="walletOutline"
|
||||
/>
|
||||
</n-grid-item>
|
||||
<n-grid-item>
|
||||
<stat-card
|
||||
label="选中日期续费"
|
||||
:value="selectedDateEvents.length"
|
||||
:suffix="`${selectedDateLabel} · ${baseCurrency} ${selectedDateConvertedAmount.toFixed(2)}`"
|
||||
:icon="todayOutline"
|
||||
/>
|
||||
</n-grid-item>
|
||||
</n-grid>
|
||||
|
||||
<n-card class="calendar-panel-card">
|
||||
<n-tabs v-model:value="tab">
|
||||
<n-tab-pane name="month" tab="月视图">
|
||||
<n-grid :cols="calendarCols" :x-gap="12" :y-gap="12">
|
||||
<n-grid-item>
|
||||
<div class="calendar-wrapper">
|
||||
<n-calendar v-model:value="selectedDateTs" @panel-change="handlePanelChange">
|
||||
<template #default="{ year, month, date }">
|
||||
<div v-if="getDaySummary(year, month, date)" class="calendar-cell-summary">
|
||||
<div class="calendar-cell-summary__count">{{ getDaySummary(year, month, date)?.count }} 笔</div>
|
||||
<div class="calendar-cell-summary__amount">
|
||||
{{ baseCurrency }} {{ getDaySummary(year, month, date)?.convertedAmount.toFixed(0) }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</n-calendar>
|
||||
</div>
|
||||
</n-grid-item>
|
||||
|
||||
<n-grid-item>
|
||||
<n-card :title="`当天续费(${selectedDateLabel})`" size="small" class="day-detail-card">
|
||||
<template #header-extra>
|
||||
<span class="day-summary-inline">
|
||||
共 {{ selectedDateEvents.length }} 笔 · {{ baseCurrency }} {{ selectedDateConvertedAmount.toFixed(2) }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<n-empty v-if="selectedDateEvents.length === 0" description="当天无续费" />
|
||||
|
||||
<n-space v-else vertical :size="10">
|
||||
<div v-for="item in selectedDateEvents" :key="item.id" class="day-event-item">
|
||||
<div class="day-event-item__title-row">
|
||||
<span class="day-event-item__title">{{ item.title }}</span>
|
||||
<n-tag size="small" :type="getSubscriptionStatusTagType(item.status)">
|
||||
{{ getSubscriptionStatusText(item.status) }}
|
||||
</n-tag>
|
||||
</div>
|
||||
|
||||
<div class="day-event-item__meta">
|
||||
{{ item.currency }} {{ item.amount.toFixed(2) }} / 折算 {{ baseCurrency }}
|
||||
{{ item.convertedAmount.toFixed(2) }}
|
||||
</div>
|
||||
</div>
|
||||
</n-space>
|
||||
</n-card>
|
||||
</n-grid-item>
|
||||
</n-grid>
|
||||
</n-tab-pane>
|
||||
|
||||
<n-tab-pane name="list" tab="列表视图">
|
||||
<n-data-table :columns="columns" :data="events" :pagination="{ pageSize: 12 }" />
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</n-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import dayjs from 'dayjs'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useWindowSize } from '@vueuse/core'
|
||||
import { NCalendar, NCard, NDataTable, NEmpty, NGrid, NGridItem, NSpace, NTabPane, NTabs, NTag } from 'naive-ui'
|
||||
import {
|
||||
CalendarClearOutline,
|
||||
CalendarOutline,
|
||||
NotificationsOutline,
|
||||
TodayOutline,
|
||||
WalletOutline
|
||||
} from '@vicons/ionicons5'
|
||||
import { api } from '@/composables/api'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
import StatCard from '@/components/StatCard.vue'
|
||||
import type { CalendarEvent } from '@/types/api'
|
||||
import { getSubscriptionStatusTagType, getSubscriptionStatusText } from '@/utils/subscription-status'
|
||||
|
||||
const { width } = useWindowSize()
|
||||
const calendarOutline = CalendarOutline
|
||||
const calendarClearOutline = CalendarClearOutline
|
||||
const notificationsOutline = NotificationsOutline
|
||||
const walletOutline = WalletOutline
|
||||
const todayOutline = TodayOutline
|
||||
|
||||
const events = ref<CalendarEvent[]>([])
|
||||
const tab = ref('month')
|
||||
const selectedDateTs = ref(dayjs().valueOf())
|
||||
const panelMonthTs = ref(dayjs().startOf('month').valueOf())
|
||||
const baseCurrency = ref('CNY')
|
||||
let latestMonthRequestId = 0
|
||||
let ignoreSelectedDateWatch = false
|
||||
const monthEventsCache = new Map<string, CalendarEvent[]>()
|
||||
|
||||
const summaryCols = computed(() => (width.value < 640 ? 1 : width.value < 1100 ? 2 : 4))
|
||||
const calendarCols = computed(() => (width.value < 1100 ? 1 : 2))
|
||||
|
||||
onMounted(async () => {
|
||||
if (width.value < 720) {
|
||||
tab.value = 'list'
|
||||
}
|
||||
await Promise.all([loadEventsForMonth(panelMonthTs.value), loadSettings()])
|
||||
})
|
||||
|
||||
watch(selectedDateTs, async (value) => {
|
||||
if (ignoreSelectedDateWatch) {
|
||||
ignoreSelectedDateWatch = false
|
||||
return
|
||||
}
|
||||
|
||||
const selectedMonth = dayjs(value).startOf('month')
|
||||
if (!selectedMonth.isSame(dayjs(panelMonthTs.value), 'month')) {
|
||||
await loadEventsForMonth(value)
|
||||
}
|
||||
})
|
||||
|
||||
async function loadEventsForMonth(monthTs: number) {
|
||||
const requestId = ++latestMonthRequestId
|
||||
const monthStart = dayjs(monthTs).startOf('month')
|
||||
const cacheKey = monthStart.format('YYYY-MM')
|
||||
panelMonthTs.value = monthStart.valueOf()
|
||||
|
||||
const cached = monthEventsCache.get(cacheKey)
|
||||
if (cached) {
|
||||
events.value = cached
|
||||
void prefetchAdjacentMonths(monthStart.valueOf())
|
||||
return
|
||||
}
|
||||
|
||||
events.value = []
|
||||
const start = monthStart.startOf('month').format('YYYY-MM-DD')
|
||||
const end = monthStart.endOf('month').format('YYYY-MM-DD')
|
||||
const rows = await api.getCalendarEvents({ start, end })
|
||||
|
||||
if (requestId !== latestMonthRequestId) return
|
||||
|
||||
monthEventsCache.set(cacheKey, rows)
|
||||
events.value = rows
|
||||
void prefetchAdjacentMonths(monthStart.valueOf())
|
||||
}
|
||||
|
||||
async function fetchMonthEvents(monthTs: number) {
|
||||
const monthStart = dayjs(monthTs).startOf('month')
|
||||
const cacheKey = monthStart.format('YYYY-MM')
|
||||
const cached = monthEventsCache.get(cacheKey)
|
||||
if (cached) return cached
|
||||
|
||||
const rows = await api.getCalendarEvents({
|
||||
start: monthStart.startOf('month').format('YYYY-MM-DD'),
|
||||
end: monthStart.endOf('month').format('YYYY-MM-DD')
|
||||
})
|
||||
monthEventsCache.set(cacheKey, rows)
|
||||
return rows
|
||||
}
|
||||
|
||||
async function prefetchAdjacentMonths(monthTs: number) {
|
||||
const currentMonth = dayjs(monthTs).startOf('month')
|
||||
await Promise.allSettled([
|
||||
fetchMonthEvents(currentMonth.add(1, 'month').valueOf()),
|
||||
fetchMonthEvents(currentMonth.subtract(1, 'month').valueOf())
|
||||
])
|
||||
}
|
||||
|
||||
async function loadSettings() {
|
||||
const settings = await api.getSettings()
|
||||
baseCurrency.value = settings.baseCurrency
|
||||
}
|
||||
|
||||
const panelMonthLabel = computed(() => dayjs(panelMonthTs.value).format('YYYY 年 M 月'))
|
||||
const selectedDateLabel = computed(() => dayjs(selectedDateTs.value).format('YYYY-MM-DD'))
|
||||
|
||||
const eventMap = computed(() => {
|
||||
const map = new Map<string, CalendarEvent[]>()
|
||||
for (const event of events.value) {
|
||||
const key = dayjs(event.date).format('YYYY-MM-DD')
|
||||
const list = map.get(key) ?? []
|
||||
list.push(event)
|
||||
map.set(key, list)
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
const selectedDateEvents = computed(() =>
|
||||
events.value.filter((item) => dayjs(item.date).format('YYYY-MM-DD') === selectedDateLabel.value)
|
||||
)
|
||||
const selectedDateConvertedAmount = computed(() => selectedDateEvents.value.reduce((sum, item) => sum + item.convertedAmount, 0))
|
||||
const monthEventCount = computed(() => events.value.length)
|
||||
const monthConvertedAmount = computed(() => events.value.reduce((sum, item) => sum + item.convertedAmount, 0))
|
||||
|
||||
const columns = [
|
||||
{ title: '订阅', key: 'title' },
|
||||
{
|
||||
title: '日期',
|
||||
key: 'date',
|
||||
render: (row: CalendarEvent) => dayjs(row.date).format('YYYY-MM-DD')
|
||||
},
|
||||
{
|
||||
title: '原始金额',
|
||||
key: 'amount',
|
||||
render: (row: CalendarEvent) => `${row.currency} ${row.amount.toFixed(2)}`
|
||||
},
|
||||
{
|
||||
title: '折算金额',
|
||||
key: 'convertedAmount',
|
||||
render: (row: CalendarEvent) => `${baseCurrency.value} ${row.convertedAmount.toFixed(2)}`
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
key: 'status',
|
||||
render: (row: CalendarEvent) => getSubscriptionStatusText(row.status)
|
||||
}
|
||||
]
|
||||
|
||||
function handlePanelChange({ year, month }: { year: number; month: number }) {
|
||||
const targetMonth = dayjs(`${year}-${String(month).padStart(2, '0')}-01`)
|
||||
const currentSelectedDay = dayjs(selectedDateTs.value).date()
|
||||
const targetSelectedDate = targetMonth.date(Math.min(currentSelectedDay, targetMonth.daysInMonth()))
|
||||
|
||||
ignoreSelectedDateWatch = true
|
||||
selectedDateTs.value = targetSelectedDate.valueOf()
|
||||
void loadEventsForMonth(targetMonth.valueOf())
|
||||
}
|
||||
|
||||
function getDaySummary(year: number, month: number, date: number) {
|
||||
const key = dayjs(`${year}-${String(month).padStart(2, '0')}-${String(date).padStart(2, '0')}`).format('YYYY-MM-DD')
|
||||
const items = eventMap.value.get(key)
|
||||
if (!items?.length) return null
|
||||
|
||||
return {
|
||||
count: items.length,
|
||||
convertedAmount: items.reduce((sum, item) => sum + item.convertedAmount, 0)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.calendar-panel-card {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.calendar-wrapper {
|
||||
max-width: 720px;
|
||||
}
|
||||
|
||||
.day-detail-card {
|
||||
min-height: 180px;
|
||||
}
|
||||
|
||||
.day-event-item {
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #eef2f7;
|
||||
border-radius: 10px;
|
||||
background: #fafcff;
|
||||
}
|
||||
|
||||
.day-event-item__title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.day-event-item__title {
|
||||
min-width: 0;
|
||||
font-weight: 600;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.day-event-item__meta {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.calendar-cell-summary {
|
||||
margin-top: 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
font-size: 11px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.calendar-cell-summary__count {
|
||||
color: #2563eb;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.calendar-cell-summary__amount {
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.day-summary-inline {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
:deep(.n-calendar .n-calendar-header) {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
:deep(.n-calendar .n-calendar-dates .n-calendar-cell) {
|
||||
min-height: 56px;
|
||||
}
|
||||
|
||||
:deep(.n-calendar .n-calendar-dates .n-calendar-cell-value) {
|
||||
padding: 3px 5px 2px;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.calendar-wrapper {
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
218
apps/web/src/pages/DashboardPage.vue
Normal file
218
apps/web/src/pages/DashboardPage.vue
Normal file
@@ -0,0 +1,218 @@
|
||||
<template>
|
||||
<div>
|
||||
<page-header title="仪表盘" subtitle="总览订阅规模、预算使用、待续费与费用分布" :icon="gridOutline" />
|
||||
|
||||
<n-grid :cols="24" :x-gap="12" :y-gap="12">
|
||||
<n-grid-item v-for="item in summaryCards" :key="item.label" :span="summarySpan">
|
||||
<stat-card :label="item.label" :value="item.value" :icon="item.icon" />
|
||||
</n-grid-item>
|
||||
|
||||
<n-grid-item :span="halfSpan">
|
||||
<n-card title="月预算使用">
|
||||
<template v-if="overview?.monthlyBudgetBase">
|
||||
<div class="budget-progress-row">
|
||||
<n-progress
|
||||
type="line"
|
||||
:percentage="formatBudgetPercentage(overview.monthlyBudgetUsageRatio)"
|
||||
:show-indicator="false"
|
||||
/>
|
||||
<span class="budget-progress-value">{{ formatBudgetPercentage(overview.monthlyBudgetUsageRatio) }}%</span>
|
||||
</div>
|
||||
<div class="budget-meta">
|
||||
已使用 {{ formatMoney(overview.monthlyEstimatedBase, baseCurrency) }} / 预算
|
||||
{{ formatMoney(overview.monthlyBudgetBase, baseCurrency) }}
|
||||
</div>
|
||||
</template>
|
||||
<n-empty v-else description="未设置月预算" />
|
||||
</n-card>
|
||||
</n-grid-item>
|
||||
|
||||
<n-grid-item :span="halfSpan">
|
||||
<n-card title="年预算使用">
|
||||
<template v-if="overview?.yearlyBudgetBase">
|
||||
<div class="budget-progress-row">
|
||||
<n-progress
|
||||
type="line"
|
||||
:percentage="formatBudgetPercentage(overview.yearlyBudgetUsageRatio)"
|
||||
status="warning"
|
||||
:show-indicator="false"
|
||||
/>
|
||||
<span class="budget-progress-value">{{ formatBudgetPercentage(overview.yearlyBudgetUsageRatio) }}%</span>
|
||||
</div>
|
||||
<div class="budget-meta">
|
||||
已使用 {{ formatMoney(overview.yearlyEstimatedBase, baseCurrency) }} / 预算
|
||||
{{ formatMoney(overview.yearlyBudgetBase, baseCurrency) }}
|
||||
</div>
|
||||
</template>
|
||||
<n-empty v-else description="未设置年预算" />
|
||||
</n-card>
|
||||
</n-grid-item>
|
||||
</n-grid>
|
||||
|
||||
<n-grid :cols="chartCols" :x-gap="12" :y-gap="12" style="margin-top: 12px">
|
||||
<n-grid-item>
|
||||
<n-card title="分类月度支出">
|
||||
<chart-view v-if="categoryOption" :option="categoryOption" />
|
||||
<n-empty v-else description="暂无数据" />
|
||||
</n-card>
|
||||
</n-grid-item>
|
||||
<n-grid-item>
|
||||
<n-card title="支付历史趋势">
|
||||
<chart-view v-if="trendOption" :option="trendOption" />
|
||||
<n-empty v-else description="暂无数据" />
|
||||
</n-card>
|
||||
</n-grid-item>
|
||||
</n-grid>
|
||||
|
||||
<n-card title="即将续费(30 天)" style="margin-top: 12px">
|
||||
<n-data-table :columns="columns" :data="overview?.upcomingRenewals ?? []" :pagination="false" />
|
||||
</n-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import dayjs from 'dayjs'
|
||||
import { computed, h } from 'vue'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import { useWindowSize } from '@vueuse/core'
|
||||
import { NCard, NDataTable, NEmpty, NGrid, NGridItem, NProgress, NTag } from 'naive-ui'
|
||||
import { CashOutline, GridOutline, LayersOutline, NotificationsOutline, WalletOutline } from '@vicons/ionicons5'
|
||||
import { api } from '@/composables/api'
|
||||
import ChartView from '@/components/ChartView.vue'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
import StatCard from '@/components/StatCard.vue'
|
||||
import type { StatisticsOverview } from '@/types/api'
|
||||
import { getSubscriptionStatusTagType, getSubscriptionStatusText } from '@/utils/subscription-status'
|
||||
|
||||
const { width } = useWindowSize()
|
||||
const gridOutline = GridOutline
|
||||
|
||||
const { data: overview } = useQuery({
|
||||
queryKey: ['statistics-overview'],
|
||||
queryFn: () => api.getStatisticsOverview()
|
||||
})
|
||||
|
||||
const { data: settings } = useQuery({
|
||||
queryKey: ['settings'],
|
||||
queryFn: () => api.getSettings()
|
||||
})
|
||||
|
||||
const baseCurrency = computed(() => settings.value?.baseCurrency ?? 'CNY')
|
||||
const summarySpan = computed(() => {
|
||||
if (width.value < 640) return 24
|
||||
if (width.value < 1100) return 12
|
||||
return 6
|
||||
})
|
||||
const halfSpan = computed(() => (width.value < 1100 ? 24 : 12))
|
||||
const chartCols = computed(() => (width.value < 1100 ? 1 : 2))
|
||||
|
||||
const summaryCards = computed(() => [
|
||||
{ label: '活跃订阅', value: overview.value?.activeSubscriptions ?? 0, icon: LayersOutline },
|
||||
{ label: '7 天内续费', value: overview.value?.upcoming7Days ?? 0, icon: NotificationsOutline },
|
||||
{
|
||||
label: '本月预计支出',
|
||||
value: overview.value ? formatMoney(overview.value.monthlyEstimatedBase, baseCurrency.value) : '--',
|
||||
icon: WalletOutline
|
||||
},
|
||||
{
|
||||
label: '年度预计支出',
|
||||
value: overview.value ? formatMoney(overview.value.yearlyEstimatedBase, baseCurrency.value) : '--',
|
||||
icon: CashOutline
|
||||
}
|
||||
])
|
||||
|
||||
const categoryOption = computed(() => {
|
||||
if (!overview.value?.categorySpend?.length) return null
|
||||
return {
|
||||
tooltip: { trigger: 'item' },
|
||||
legend: { bottom: 0 },
|
||||
series: [
|
||||
{
|
||||
type: 'pie',
|
||||
radius: ['40%', '68%'],
|
||||
data: overview.value.categorySpend
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
const trendOption = computed(() => {
|
||||
if (!overview.value?.monthlyTrend?.length) return null
|
||||
return {
|
||||
tooltip: { trigger: 'axis' },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: overview.value.monthlyTrend.map((item) => item.month)
|
||||
},
|
||||
yAxis: { type: 'value' },
|
||||
series: [
|
||||
{
|
||||
data: overview.value.monthlyTrend.map((item) => item.amount),
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
areaStyle: {}
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
const columns = [
|
||||
{ title: '订阅', key: 'name' },
|
||||
{
|
||||
title: '下次续费',
|
||||
key: 'nextRenewalDate',
|
||||
render: (row: StatisticsOverview['upcomingRenewals'][number]) => dayjs(row.nextRenewalDate).format('YYYY-MM-DD')
|
||||
},
|
||||
{
|
||||
title: '原始金额',
|
||||
key: 'amount',
|
||||
render: (row: StatisticsOverview['upcomingRenewals'][number]) => formatMoney(row.amount, row.currency)
|
||||
},
|
||||
{
|
||||
title: '折算金额',
|
||||
key: 'convertedAmount',
|
||||
render: (row: StatisticsOverview['upcomingRenewals'][number]) => formatMoney(row.convertedAmount, baseCurrency.value)
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
key: 'status',
|
||||
render: (row: StatisticsOverview['upcomingRenewals'][number]) =>
|
||||
h(NTag, { type: getSubscriptionStatusTagType(row.status) }, { default: () => getSubscriptionStatusText(row.status) })
|
||||
}
|
||||
]
|
||||
|
||||
function formatMoney(amount: number, currency: string) {
|
||||
return `${currency} ${amount.toFixed(2)}`
|
||||
}
|
||||
|
||||
function formatBudgetPercentage(ratio?: number | null) {
|
||||
const raw = Math.min((ratio ?? 0) * 100, 100)
|
||||
return Math.round(raw * 100) / 100
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.budget-progress-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.budget-progress-row :deep(.n-progress) {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.budget-progress-value {
|
||||
flex-shrink: 0;
|
||||
min-width: 56px;
|
||||
text-align: right;
|
||||
color: #334155;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.budget-meta {
|
||||
margin-top: 10px;
|
||||
color: #64748b;
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
115
apps/web/src/pages/LoginPage.vue
Normal file
115
apps/web/src/pages/LoginPage.vue
Normal file
@@ -0,0 +1,115 @@
|
||||
<template>
|
||||
<div class="login-page">
|
||||
<n-card class="login-card" :bordered="false">
|
||||
<div class="login-header">
|
||||
<div class="login-header__icon">
|
||||
<n-icon :size="22">
|
||||
<lock-closed-outline />
|
||||
</n-icon>
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="login-title">登录 SubTracker</h1>
|
||||
<p class="login-subtitle">默认账号:admin / admin</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<n-form :model="form" label-placement="top">
|
||||
<n-form-item label="用户名">
|
||||
<n-input v-model:value="form.username" placeholder="请输入用户名" />
|
||||
</n-form-item>
|
||||
<n-form-item label="密码">
|
||||
<n-input v-model:value="form.password" type="password" show-password-on="click" placeholder="请输入密码" />
|
||||
</n-form-item>
|
||||
<n-button type="primary" block @click="submit">登录</n-button>
|
||||
</n-form>
|
||||
</n-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { NButton, NCard, NForm, NFormItem, NIcon, NInput, useMessage } from 'naive-ui'
|
||||
import { LockClosedOutline } from '@vicons/ionicons5'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const message = useMessage()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const form = reactive({
|
||||
username: 'admin',
|
||||
password: 'admin'
|
||||
})
|
||||
|
||||
async function submit() {
|
||||
try {
|
||||
await authStore.login(form.username, form.password)
|
||||
message.success('登录成功')
|
||||
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/dashboard'
|
||||
await router.replace(redirect)
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '登录失败')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
background: linear-gradient(135deg, #eff6ff 0%, #eef2ff 100%);
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: min(420px, 100%);
|
||||
border-radius: 22px;
|
||||
box-shadow: 0 20px 60px rgba(37, 99, 235, 0.14);
|
||||
}
|
||||
|
||||
.login-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.login-header__icon {
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
border-radius: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, #2563eb 0%, #4f46e5 100%);
|
||||
}
|
||||
|
||||
.login-title {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.login-subtitle {
|
||||
margin: 6px 0 0;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.login-page {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
border-radius: 18px;
|
||||
}
|
||||
|
||||
.login-title {
|
||||
font-size: 22px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
655
apps/web/src/pages/SettingsPage.vue
Normal file
655
apps/web/src/pages/SettingsPage.vue
Normal file
@@ -0,0 +1,655 @@
|
||||
<template>
|
||||
<div>
|
||||
<page-header
|
||||
title="系统设置"
|
||||
subtitle="管理基础参数、预算、汇率、通知与 AI 识别"
|
||||
:icon="settingsOutline"
|
||||
icon-background="linear-gradient(135deg, #64748b 0%, #334155 100%)"
|
||||
/>
|
||||
|
||||
<n-grid :cols="gridCols" :x-gap="12" :y-gap="12">
|
||||
<n-grid-item>
|
||||
<n-card title="基础设置" class="settings-card">
|
||||
<n-form :model="settingsForm" label-placement="top">
|
||||
<n-grid :cols="formCols" :x-gap="12">
|
||||
<n-grid-item>
|
||||
<n-form-item label="基准货币">
|
||||
<n-select v-model:value="settingsForm.baseCurrency" :options="allCurrencyOptions" filterable />
|
||||
</n-form-item>
|
||||
</n-grid-item>
|
||||
<n-grid-item>
|
||||
<n-form-item label="默认提醒天数">
|
||||
<n-input-number v-model:value="settingsForm.defaultNotifyDays" :min="0" :max="365" style="width: 100%" />
|
||||
</n-form-item>
|
||||
</n-grid-item>
|
||||
</n-grid>
|
||||
|
||||
<n-grid :cols="formCols" :x-gap="12">
|
||||
<n-grid-item>
|
||||
<n-form-item label="月预算(基准货币)">
|
||||
<n-input-number v-model:value="settingsForm.monthlyBudgetBase" :min="0" :precision="2" style="width: 100%" />
|
||||
</n-form-item>
|
||||
</n-grid-item>
|
||||
<n-grid-item>
|
||||
<n-form-item label="年预算(基准货币)">
|
||||
<n-input-number v-model:value="settingsForm.yearlyBudgetBase" :min="0" :precision="2" style="width: 100%" />
|
||||
</n-form-item>
|
||||
</n-grid-item>
|
||||
</n-grid>
|
||||
|
||||
<n-form-item>
|
||||
<n-switch v-model:value="settingsForm.enableCategoryBudgets" />
|
||||
<span class="switch-label">启用分类月预算</span>
|
||||
</n-form-item>
|
||||
|
||||
<div v-if="settingsForm.enableCategoryBudgets" class="category-budget-grid">
|
||||
<div v-for="category in categories" :key="category.id" class="category-budget-item">
|
||||
<div class="category-budget-item__name">{{ category.name }}</div>
|
||||
<n-input-number
|
||||
v-model:value="settingsForm.categoryBudgets[category.id]"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
placeholder="未设置"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<n-space style="margin-top: 12px">
|
||||
<n-button type="primary" @click="saveBasicSettings">
|
||||
<template #icon>
|
||||
<n-icon><save-outline /></n-icon>
|
||||
</template>
|
||||
保存
|
||||
</n-button>
|
||||
</n-space>
|
||||
</n-form>
|
||||
</n-card>
|
||||
</n-grid-item>
|
||||
|
||||
<n-grid-item>
|
||||
<n-card title="汇率快照" class="settings-card">
|
||||
<n-descriptions v-if="snapshot" :column="1" bordered>
|
||||
<n-descriptions-item label="基准货币">{{ snapshot.baseCurrency }}</n-descriptions-item>
|
||||
<n-descriptions-item label="来源名称">{{ snapshot.provider }}</n-descriptions-item>
|
||||
<n-descriptions-item label="接口地址">
|
||||
<a :href="providerUrl" target="_blank" rel="noreferrer" class="provider-link">{{ providerUrl }}</a>
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="拉取时间">{{ formatTime(snapshot.fetchedAt) }}</n-descriptions-item>
|
||||
<n-descriptions-item label="数据状态">
|
||||
<n-tag :type="snapshot.isStale ? 'warning' : 'success'">{{ snapshot.isStale ? '旧快照' : '最新' }}</n-tag>
|
||||
</n-descriptions-item>
|
||||
</n-descriptions>
|
||||
|
||||
<n-space style="margin-top: 12px">
|
||||
<n-button @click="refreshRates">
|
||||
<template #icon>
|
||||
<n-icon><refresh-outline /></n-icon>
|
||||
</template>
|
||||
刷新
|
||||
</n-button>
|
||||
</n-space>
|
||||
</n-card>
|
||||
</n-grid-item>
|
||||
|
||||
<n-grid-item>
|
||||
<n-card title="当前汇率(常用货币)" class="settings-card">
|
||||
<template #header-extra>
|
||||
<n-space>
|
||||
<n-tag type="success">基准货币 {{ settingsForm.baseCurrency }}</n-tag>
|
||||
<n-tag type="info">支持 {{ supportedCurrencyCount }} 种货币</n-tag>
|
||||
</n-space>
|
||||
</template>
|
||||
<n-data-table :columns="rateColumns" :data="currentRates" :pagination="false" />
|
||||
</n-card>
|
||||
</n-grid-item>
|
||||
|
||||
<n-grid-item>
|
||||
<n-card title="汇率转换器" class="settings-card">
|
||||
<n-space vertical style="width: 100%">
|
||||
<n-grid :cols="formCols" :x-gap="12" :y-gap="12">
|
||||
<n-grid-item>
|
||||
<n-select v-model:value="sourceCurrency" :options="allCurrencyOptions" filterable placeholder="源货币" />
|
||||
</n-grid-item>
|
||||
<n-grid-item>
|
||||
<n-select v-model:value="targetCurrency" :options="allCurrencyOptions" filterable placeholder="目标货币" />
|
||||
</n-grid-item>
|
||||
</n-grid>
|
||||
<n-input-number v-model:value="converterAmount" :min="0" :precision="4" style="width: 100%" />
|
||||
<n-card size="small" embedded>
|
||||
<template v-if="sourceCurrency && targetCurrency">
|
||||
<div class="converter-main">
|
||||
{{ Number(converterAmount || 0).toFixed(4) }} {{ sourceCurrency }} = {{ convertedPreview.toFixed(4) }} {{ targetCurrency }}
|
||||
</div>
|
||||
<div class="converter-sub">1 {{ sourceCurrency }} = {{ converterRateDisplay }} {{ targetCurrency }}</div>
|
||||
</template>
|
||||
<template v-else>请选择要转换的货币</template>
|
||||
</n-card>
|
||||
</n-space>
|
||||
</n-card>
|
||||
</n-grid-item>
|
||||
|
||||
<n-grid-item :span="gridSpanFull">
|
||||
<n-card title="通知设置" class="settings-card">
|
||||
<n-alert type="info" :show-icon="false" style="margin-bottom: 12px">
|
||||
统一管理邮箱、PushPlus 与 Webhook。每个渠道都可以单独保存并单独测试。
|
||||
</n-alert>
|
||||
|
||||
<n-grid :cols="notificationGridCols" :x-gap="12" :y-gap="12">
|
||||
<n-grid-item>
|
||||
<div class="channel-card">
|
||||
<div class="channel-card__header">
|
||||
<span>邮箱通知</span>
|
||||
<n-switch v-model:value="settingsForm.emailNotificationsEnabled" />
|
||||
</div>
|
||||
<n-form label-placement="top">
|
||||
<n-form-item label="SMTP Host">
|
||||
<n-input v-model:value="settingsForm.emailConfig.host" />
|
||||
</n-form-item>
|
||||
<n-grid :cols="formCols" :x-gap="8">
|
||||
<n-grid-item>
|
||||
<n-form-item label="端口">
|
||||
<n-input-number v-model:value="settingsForm.emailConfig.port" :min="1" :max="65535" style="width: 100%" />
|
||||
</n-form-item>
|
||||
</n-grid-item>
|
||||
<n-grid-item>
|
||||
<n-form-item label="Secure">
|
||||
<n-switch v-model:value="settingsForm.emailConfig.secure" />
|
||||
</n-form-item>
|
||||
</n-grid-item>
|
||||
</n-grid>
|
||||
<n-form-item label="用户名">
|
||||
<n-input v-model:value="settingsForm.emailConfig.username" />
|
||||
</n-form-item>
|
||||
<n-form-item label="密码">
|
||||
<n-input v-model:value="settingsForm.emailConfig.password" type="password" show-password-on="click" />
|
||||
</n-form-item>
|
||||
<n-form-item label="发件人">
|
||||
<n-input v-model:value="settingsForm.emailConfig.from" placeholder="SubTracker <noreply@example.com>" />
|
||||
</n-form-item>
|
||||
<n-form-item label="收件人">
|
||||
<n-input v-model:value="settingsForm.emailConfig.to" placeholder="多个邮箱请用英文逗号分隔" />
|
||||
</n-form-item>
|
||||
<n-space>
|
||||
<n-button @click="saveEmailSettings">保存</n-button>
|
||||
<n-button type="primary" @click="testEmail">测试</n-button>
|
||||
</n-space>
|
||||
</n-form>
|
||||
</div>
|
||||
</n-grid-item>
|
||||
|
||||
<n-grid-item>
|
||||
<div class="channel-card">
|
||||
<div class="channel-card__header">
|
||||
<span>PushPlus</span>
|
||||
<n-switch v-model:value="settingsForm.pushplusNotificationsEnabled" />
|
||||
</div>
|
||||
<n-form label-placement="top">
|
||||
<n-form-item label="Token">
|
||||
<n-input v-model:value="settingsForm.pushplusConfig.token" />
|
||||
</n-form-item>
|
||||
<n-form-item label="Topic">
|
||||
<n-input v-model:value="settingsForm.pushplusConfig.topic" placeholder="可选" />
|
||||
</n-form-item>
|
||||
<n-space>
|
||||
<n-button @click="savePushplusSettings">保存</n-button>
|
||||
<n-button type="primary" @click="testPushplus">测试</n-button>
|
||||
</n-space>
|
||||
</n-form>
|
||||
</div>
|
||||
</n-grid-item>
|
||||
|
||||
<n-grid-item>
|
||||
<div class="channel-card">
|
||||
<div class="channel-card__header">
|
||||
<span>Webhook</span>
|
||||
<n-switch v-model:value="webhookForm.enabled" />
|
||||
</div>
|
||||
<n-form label-placement="top">
|
||||
<n-form-item label="URL">
|
||||
<n-input v-model:value="webhookForm.url" placeholder="https://example.com/hook" />
|
||||
</n-form-item>
|
||||
<n-form-item label="Secret">
|
||||
<n-input v-model:value="webhookForm.secret" type="password" show-password-on="click" />
|
||||
</n-form-item>
|
||||
<n-space>
|
||||
<n-button @click="saveWebhook">保存</n-button>
|
||||
<n-button type="primary" @click="testWebhook">测试</n-button>
|
||||
</n-space>
|
||||
</n-form>
|
||||
</div>
|
||||
</n-grid-item>
|
||||
</n-grid>
|
||||
</n-card>
|
||||
</n-grid-item>
|
||||
|
||||
<n-grid-item>
|
||||
<n-card title="AI 识别设置" class="settings-card">
|
||||
<n-form :model="settingsForm.aiConfig" label-placement="top">
|
||||
<n-form-item>
|
||||
<n-switch v-model:value="settingsForm.aiConfig.enabled" />
|
||||
<span class="switch-label">启用 AI 识别</span>
|
||||
</n-form-item>
|
||||
|
||||
<n-grid :cols="formCols" :x-gap="12" :y-gap="12">
|
||||
<n-grid-item>
|
||||
<n-form-item label="Provider 名称">
|
||||
<n-input v-model:value="settingsForm.aiConfig.providerName" />
|
||||
</n-form-item>
|
||||
</n-grid-item>
|
||||
<n-grid-item>
|
||||
<n-form-item label="Model">
|
||||
<n-input v-model:value="settingsForm.aiConfig.model" />
|
||||
</n-form-item>
|
||||
</n-grid-item>
|
||||
</n-grid>
|
||||
|
||||
<n-form-item label="API Base URL">
|
||||
<n-input v-model:value="settingsForm.aiConfig.baseUrl" placeholder="https://api.deepseek.com" />
|
||||
</n-form-item>
|
||||
<n-form-item label="API Key">
|
||||
<n-input v-model:value="settingsForm.aiConfig.apiKey" type="password" show-password-on="click" />
|
||||
</n-form-item>
|
||||
<n-form-item label="请求超时(毫秒)">
|
||||
<n-input-number v-model:value="settingsForm.aiConfig.timeoutMs" :min="5000" :max="120000" style="width: 100%" />
|
||||
</n-form-item>
|
||||
<n-form-item label="自定义提示词">
|
||||
<n-input
|
||||
v-model:value="settingsForm.aiConfig.promptTemplate"
|
||||
type="textarea"
|
||||
:autosize="{ minRows: 4, maxRows: 8 }"
|
||||
placeholder="留空则使用系统默认提示词"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-space>
|
||||
<n-button @click="saveAiSettings">保存</n-button>
|
||||
<n-button type="primary" @click="testAiSettings">测试</n-button>
|
||||
</n-space>
|
||||
</n-form>
|
||||
</n-card>
|
||||
</n-grid-item>
|
||||
|
||||
<n-grid-item>
|
||||
<n-card title="登录凭据" class="settings-card">
|
||||
<n-form :model="credentialsForm" label-placement="top">
|
||||
<n-form-item label="原用户名">
|
||||
<n-input v-model:value="credentialsForm.oldUsername" />
|
||||
</n-form-item>
|
||||
<n-form-item label="原密码">
|
||||
<n-input v-model:value="credentialsForm.oldPassword" type="password" show-password-on="click" />
|
||||
</n-form-item>
|
||||
<n-form-item label="新用户名">
|
||||
<n-input v-model:value="credentialsForm.newUsername" />
|
||||
</n-form-item>
|
||||
<n-form-item label="新密码">
|
||||
<n-input v-model:value="credentialsForm.newPassword" type="password" show-password-on="click" />
|
||||
</n-form-item>
|
||||
<n-button type="primary" @click="submitCredentialsChange">修改</n-button>
|
||||
</n-form>
|
||||
</n-card>
|
||||
</n-grid-item>
|
||||
</n-grid>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import dayjs from 'dayjs'
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { useWindowSize } from '@vueuse/core'
|
||||
import {
|
||||
NAlert,
|
||||
NButton,
|
||||
NCard,
|
||||
NDataTable,
|
||||
NDescriptions,
|
||||
NDescriptionsItem,
|
||||
NForm,
|
||||
NFormItem,
|
||||
NGrid,
|
||||
NGridItem,
|
||||
NIcon,
|
||||
NInput,
|
||||
NInputNumber,
|
||||
NSelect,
|
||||
NSpace,
|
||||
NSwitch,
|
||||
NTag,
|
||||
useMessage
|
||||
} from 'naive-ui'
|
||||
import { RefreshOutline, SaveOutline, SettingsOutline } from '@vicons/ionicons5'
|
||||
import { api } from '@/composables/api'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { buildCurrencyOptions } from '@/utils/currency'
|
||||
import type { Category, ChangeCredentialsPayload, ExchangeRateSnapshot, NotificationWebhookSettings, Settings } from '@/types/api'
|
||||
|
||||
const message = useMessage()
|
||||
const authStore = useAuthStore()
|
||||
const { width } = useWindowSize()
|
||||
const settingsOutline = SettingsOutline
|
||||
|
||||
const settingsForm = reactive<Settings>({
|
||||
baseCurrency: 'CNY',
|
||||
defaultNotifyDays: 3,
|
||||
monthlyBudgetBase: null,
|
||||
yearlyBudgetBase: null,
|
||||
enableCategoryBudgets: false,
|
||||
categoryBudgets: {},
|
||||
emailNotificationsEnabled: false,
|
||||
pushplusNotificationsEnabled: false,
|
||||
emailConfig: {
|
||||
host: '',
|
||||
port: 587,
|
||||
secure: false,
|
||||
username: '',
|
||||
password: '',
|
||||
from: '',
|
||||
to: ''
|
||||
},
|
||||
pushplusConfig: {
|
||||
token: '',
|
||||
topic: ''
|
||||
},
|
||||
aiConfig: {
|
||||
enabled: false,
|
||||
providerName: 'DeepSeek',
|
||||
baseUrl: 'https://api.deepseek.com',
|
||||
apiKey: '',
|
||||
model: 'deepseek-chat',
|
||||
timeoutMs: 30000,
|
||||
promptTemplate: ''
|
||||
}
|
||||
})
|
||||
|
||||
const credentialsForm = reactive<ChangeCredentialsPayload>({
|
||||
oldUsername: '',
|
||||
oldPassword: '',
|
||||
newUsername: '',
|
||||
newPassword: ''
|
||||
})
|
||||
|
||||
const webhookForm = reactive<NotificationWebhookSettings>({
|
||||
id: '',
|
||||
enabled: false,
|
||||
url: '',
|
||||
secret: ''
|
||||
})
|
||||
|
||||
const snapshot = ref<ExchangeRateSnapshot | null>(null)
|
||||
const categories = ref<Category[]>([])
|
||||
const sourceCurrency = ref('USD')
|
||||
const targetCurrency = ref('CNY')
|
||||
const converterAmount = ref(1)
|
||||
|
||||
const isMobile = computed(() => width.value < 960)
|
||||
const formCols = computed(() => (width.value < 640 ? 1 : 2))
|
||||
const gridCols = computed(() => (isMobile.value ? 1 : 2))
|
||||
const notificationGridCols = computed(() => (isMobile.value ? 1 : 3))
|
||||
const gridSpanFull = computed(() => (isMobile.value ? 1 : 2))
|
||||
const watchedCurrencies = ['CNY', 'USD', 'EUR', 'GBP', 'JPY', 'HKD']
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadSettings(), loadSnapshot(), loadCategories(), loadWebhook()])
|
||||
})
|
||||
|
||||
async function loadSettings() {
|
||||
const settings = await api.getSettings()
|
||||
Object.assign(settingsForm, settings)
|
||||
credentialsForm.oldUsername = authStore.username
|
||||
credentialsForm.newUsername = authStore.username
|
||||
targetCurrency.value = settings.baseCurrency
|
||||
}
|
||||
|
||||
async function loadSnapshot() {
|
||||
snapshot.value = await api.getExchangeRateSnapshot()
|
||||
}
|
||||
|
||||
async function loadCategories() {
|
||||
categories.value = await api.getCategories()
|
||||
}
|
||||
|
||||
async function loadWebhook() {
|
||||
const current = await api.getNotificationWebhook()
|
||||
Object.assign(webhookForm, current)
|
||||
}
|
||||
|
||||
async function saveBasicSettings() {
|
||||
await api.updateSettings({
|
||||
baseCurrency: settingsForm.baseCurrency.toUpperCase(),
|
||||
defaultNotifyDays: settingsForm.defaultNotifyDays,
|
||||
monthlyBudgetBase: settingsForm.monthlyBudgetBase,
|
||||
yearlyBudgetBase: settingsForm.yearlyBudgetBase,
|
||||
enableCategoryBudgets: settingsForm.enableCategoryBudgets,
|
||||
categoryBudgets: settingsForm.categoryBudgets
|
||||
})
|
||||
message.success('基础设置已保存')
|
||||
targetCurrency.value = settingsForm.baseCurrency.toUpperCase()
|
||||
await loadSnapshot()
|
||||
}
|
||||
|
||||
async function saveEmailSettings() {
|
||||
await api.updateSettings({
|
||||
emailNotificationsEnabled: settingsForm.emailNotificationsEnabled,
|
||||
emailConfig: settingsForm.emailConfig
|
||||
})
|
||||
message.success('邮箱通知已保存')
|
||||
}
|
||||
|
||||
async function savePushplusSettings() {
|
||||
await api.updateSettings({
|
||||
pushplusNotificationsEnabled: settingsForm.pushplusNotificationsEnabled,
|
||||
pushplusConfig: settingsForm.pushplusConfig
|
||||
})
|
||||
message.success('PushPlus 已保存')
|
||||
}
|
||||
|
||||
async function saveAiSettings() {
|
||||
await api.updateSettings({ aiConfig: settingsForm.aiConfig })
|
||||
message.success('AI 识别设置已保存')
|
||||
}
|
||||
|
||||
async function testAiSettings() {
|
||||
try {
|
||||
const result = await api.testAiConfigurationWithPayload(settingsForm.aiConfig)
|
||||
message.success(`AI 测试成功:${result.providerName} / ${result.model}`)
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : 'AI 测试失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshRates() {
|
||||
snapshot.value = await api.refreshExchangeRates()
|
||||
message.success('汇率已刷新')
|
||||
}
|
||||
|
||||
async function submitCredentialsChange() {
|
||||
const result = await api.changeCredentials(credentialsForm)
|
||||
authStore.setSession(result.token, result.user.username)
|
||||
credentialsForm.oldPassword = ''
|
||||
credentialsForm.newPassword = ''
|
||||
credentialsForm.oldUsername = result.user.username
|
||||
credentialsForm.newUsername = result.user.username
|
||||
message.success('登录凭据已更新')
|
||||
}
|
||||
|
||||
async function testEmail() {
|
||||
try {
|
||||
await api.testEmailNotificationWithPayload(settingsForm.emailConfig)
|
||||
message.success('测试邮件已发送')
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '邮箱测试失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function testPushplus() {
|
||||
try {
|
||||
await api.testPushplusNotificationWithPayload(settingsForm.pushplusConfig)
|
||||
message.success('PushPlus 测试消息已发送')
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : 'PushPlus 测试失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function saveWebhook() {
|
||||
const saved = await api.updateNotificationWebhook({
|
||||
url: webhookForm.url.trim(),
|
||||
secret: webhookForm.secret.trim(),
|
||||
enabled: webhookForm.enabled
|
||||
})
|
||||
Object.assign(webhookForm, saved)
|
||||
message.success('Webhook 已保存')
|
||||
}
|
||||
|
||||
async function testWebhook() {
|
||||
try {
|
||||
await api.testWebhookNotificationWithPayload({
|
||||
url: webhookForm.url,
|
||||
secret: webhookForm.secret,
|
||||
enabled: webhookForm.enabled
|
||||
})
|
||||
message.success('Webhook 测试已发送')
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : 'Webhook 测试失败')
|
||||
}
|
||||
}
|
||||
|
||||
const supportedCurrencies = computed(() => {
|
||||
if (!snapshot.value) return ['CNY', 'USD', 'EUR', 'GBP', 'JPY', 'HKD']
|
||||
return Array.from(new Set([snapshot.value.baseCurrency, ...Object.keys(snapshot.value.rates)])).sort()
|
||||
})
|
||||
|
||||
const supportedCurrencyCount = computed(() => supportedCurrencies.value.length)
|
||||
const allCurrencyOptions = computed(() => buildCurrencyOptions(supportedCurrencies.value))
|
||||
|
||||
const currentRates = computed(() => {
|
||||
if (!snapshot.value) return []
|
||||
const targetBaseCurrency = settingsForm.baseCurrency.toUpperCase()
|
||||
const snapshotBase = snapshot.value.baseCurrency
|
||||
const rates = snapshot.value.rates
|
||||
|
||||
return watchedCurrencies
|
||||
.filter((code) => code !== targetBaseCurrency && rates[code])
|
||||
.map((code) => ({
|
||||
currency: code,
|
||||
rate: Number(
|
||||
(
|
||||
(code === snapshotBase ? 1 : 1 / (rates[code] ?? 1)) *
|
||||
(targetBaseCurrency === snapshotBase ? 1 : rates[targetBaseCurrency] ?? 1)
|
||||
).toFixed(4)
|
||||
)
|
||||
}))
|
||||
})
|
||||
|
||||
const convertedPreview = computed(() => {
|
||||
if (!snapshot.value || !sourceCurrency.value || !targetCurrency.value) return 0
|
||||
|
||||
const from = sourceCurrency.value.toUpperCase()
|
||||
const to = targetCurrency.value.toUpperCase()
|
||||
const rates = snapshot.value.rates
|
||||
const base = snapshot.value.baseCurrency
|
||||
|
||||
const sourceToBase = from === base ? 1 : 1 / (rates[from] ?? 1)
|
||||
const baseToTarget = to === base ? 1 : rates[to] ?? 1
|
||||
return Number((Number(converterAmount.value || 0) * sourceToBase * baseToTarget).toFixed(4))
|
||||
})
|
||||
|
||||
const converterRateDisplay = computed(() => {
|
||||
if (!converterAmount.value) return '0.0000'
|
||||
return Number((convertedPreview.value / Number(converterAmount.value || 1)).toFixed(4)).toFixed(4)
|
||||
})
|
||||
|
||||
const providerUrl = 'https://open.er-api.com/v6/latest'
|
||||
|
||||
const rateColumns = computed(() => [
|
||||
{ title: '货币', key: 'currency' },
|
||||
{
|
||||
title: settingsForm.baseCurrency.toUpperCase(),
|
||||
key: 'rate',
|
||||
render: (row: { rate: number }) => row.rate.toFixed(4)
|
||||
}
|
||||
])
|
||||
|
||||
function formatTime(value: string) {
|
||||
return dayjs(value).format('YYYY-MM-DD HH:mm:ss')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.settings-card {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.switch-label {
|
||||
margin-left: 10px;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.category-budget-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.category-budget-item {
|
||||
padding: 10px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.category-budget-item__name {
|
||||
margin-bottom: 8px;
|
||||
font-size: 13px;
|
||||
color: #334155;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.provider-link {
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
color: #2563eb;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.converter-main {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.converter-sub {
|
||||
margin-top: 6px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.channel-card {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 14px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 14px;
|
||||
background: #f8fafc;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.channel-card__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
:deep(.n-grid-item),
|
||||
:deep(.n-form-item),
|
||||
:deep(.n-form-item-blank),
|
||||
:deep(.n-input),
|
||||
:deep(.n-input-number),
|
||||
:deep(.n-base-selection) {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
</style>
|
||||
128
apps/web/src/pages/StatisticsPage.vue
Normal file
128
apps/web/src/pages/StatisticsPage.vue
Normal file
@@ -0,0 +1,128 @@
|
||||
<template>
|
||||
<div>
|
||||
<page-header
|
||||
title="费用统计"
|
||||
subtitle="从分类、月份、币种维度分析支出"
|
||||
:icon="barChartOutline"
|
||||
icon-background="linear-gradient(135deg, #0ea5e9 0%, #2563eb 100%)"
|
||||
/>
|
||||
|
||||
<n-grid :cols="gridCols" :x-gap="12" :y-gap="12">
|
||||
<n-grid-item>
|
||||
<n-card title="月度趋势">
|
||||
<chart-view v-if="trendOption" :option="trendOption" />
|
||||
<n-empty v-else description="暂无数据" />
|
||||
</n-card>
|
||||
</n-grid-item>
|
||||
<n-grid-item>
|
||||
<n-card title="分类占比">
|
||||
<chart-view v-if="categoryOption" :option="categoryOption" />
|
||||
<n-empty v-else description="暂无数据" />
|
||||
</n-card>
|
||||
</n-grid-item>
|
||||
</n-grid>
|
||||
|
||||
<n-grid :cols="gridCols" :x-gap="12" :y-gap="12" style="margin-top: 12px">
|
||||
<n-grid-item>
|
||||
<n-card title="币种分布">
|
||||
<n-data-table :columns="currencyColumns" :data="overview?.currencyDistribution ?? []" :pagination="false" />
|
||||
</n-card>
|
||||
</n-grid-item>
|
||||
<n-grid-item>
|
||||
<n-card title="即将续费金额(30 天)">
|
||||
<n-data-table :columns="upcomingColumns" :data="overview?.upcomingRenewals ?? []" :pagination="false" />
|
||||
</n-card>
|
||||
</n-grid-item>
|
||||
</n-grid>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import dayjs from 'dayjs'
|
||||
import { computed } from 'vue'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import { useWindowSize } from '@vueuse/core'
|
||||
import { NCard, NDataTable, NEmpty, NGrid, NGridItem } from 'naive-ui'
|
||||
import { BarChartOutline } from '@vicons/ionicons5'
|
||||
import { api } from '@/composables/api'
|
||||
import ChartView from '@/components/ChartView.vue'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
import type { StatisticsOverview } from '@/types/api'
|
||||
|
||||
const { width } = useWindowSize()
|
||||
const barChartOutline = BarChartOutline
|
||||
|
||||
const { data: overview } = useQuery({
|
||||
queryKey: ['statistics-overview-full'],
|
||||
queryFn: api.getStatisticsOverview
|
||||
})
|
||||
|
||||
const { data: settings } = useQuery({
|
||||
queryKey: ['settings-currency'],
|
||||
queryFn: api.getSettings
|
||||
})
|
||||
|
||||
const baseCurrency = computed(() => settings.value?.baseCurrency ?? 'CNY')
|
||||
const gridCols = computed(() => (width.value < 1100 ? 1 : 2))
|
||||
|
||||
const trendOption = computed(() => {
|
||||
if (!overview.value?.monthlyTrend.length) return null
|
||||
return {
|
||||
tooltip: { trigger: 'axis' },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: overview.value.monthlyTrend.map((item) => item.month)
|
||||
},
|
||||
yAxis: { type: 'value' },
|
||||
series: [
|
||||
{
|
||||
type: 'bar',
|
||||
data: overview.value.monthlyTrend.map((item) => item.amount),
|
||||
itemStyle: { color: '#3b82f6' }
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
const categoryOption = computed(() => {
|
||||
if (!overview.value?.categorySpend.length) return null
|
||||
return {
|
||||
tooltip: { trigger: 'item' },
|
||||
series: [
|
||||
{
|
||||
type: 'pie',
|
||||
radius: ['35%', '65%'],
|
||||
data: overview.value.categorySpend
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
const currencyColumns = [
|
||||
{ title: '货币', key: 'currency' },
|
||||
{
|
||||
title: '金额',
|
||||
key: 'amount',
|
||||
render: (row: StatisticsOverview['currencyDistribution'][number]) => `${row.currency} ${Number(row.amount).toFixed(2)}`
|
||||
}
|
||||
]
|
||||
|
||||
const upcomingColumns = [
|
||||
{ title: '订阅', key: 'name' },
|
||||
{
|
||||
title: '日期',
|
||||
key: 'nextRenewalDate',
|
||||
render: (row: StatisticsOverview['upcomingRenewals'][number]) => dayjs(row.nextRenewalDate).format('YYYY-MM-DD')
|
||||
},
|
||||
{
|
||||
title: '原始金额',
|
||||
key: 'amount',
|
||||
render: (row: StatisticsOverview['upcomingRenewals'][number]) => `${row.currency} ${Number(row.amount).toFixed(2)}`
|
||||
},
|
||||
{
|
||||
title: '折算金额',
|
||||
key: 'convertedAmount',
|
||||
render: (row: StatisticsOverview['upcomingRenewals'][number]) => `${baseCurrency.value} ${Number(row.convertedAmount).toFixed(2)}`
|
||||
}
|
||||
]
|
||||
</script>
|
||||
1087
apps/web/src/pages/SubscriptionsPage.vue
Normal file
1087
apps/web/src/pages/SubscriptionsPage.vue
Normal file
File diff suppressed because it is too large
Load Diff
67
apps/web/src/router/index.ts
Normal file
67
apps/web/src/router/index.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { getStoredToken } from '@/utils/auth-storage'
|
||||
|
||||
export const routes = [
|
||||
{ path: '/', redirect: '/dashboard' },
|
||||
{
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
component: () => import('@/pages/LoginPage.vue'),
|
||||
meta: { public: true, label: '登录' }
|
||||
},
|
||||
{
|
||||
path: '/dashboard',
|
||||
name: 'dashboard',
|
||||
component: () => import('@/pages/DashboardPage.vue'),
|
||||
meta: { label: '仪表盘' }
|
||||
},
|
||||
{
|
||||
path: '/subscriptions',
|
||||
name: 'subscriptions',
|
||||
component: () => import('@/pages/SubscriptionsPage.vue'),
|
||||
meta: { label: '订阅管理' }
|
||||
},
|
||||
{
|
||||
path: '/calendar',
|
||||
name: 'calendar',
|
||||
component: () => import('@/pages/CalendarPage.vue'),
|
||||
meta: { label: '订阅日历' }
|
||||
},
|
||||
{
|
||||
path: '/statistics',
|
||||
name: 'statistics',
|
||||
component: () => import('@/pages/StatisticsPage.vue'),
|
||||
meta: { label: '费用统计' }
|
||||
},
|
||||
{
|
||||
path: '/settings',
|
||||
name: 'settings',
|
||||
component: () => import('@/pages/SettingsPage.vue'),
|
||||
meta: { label: '系统设置' }
|
||||
}
|
||||
]
|
||||
|
||||
export const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes
|
||||
})
|
||||
|
||||
router.beforeEach((to) => {
|
||||
const token = getStoredToken()
|
||||
|
||||
if (to.meta.public) {
|
||||
if (to.path === '/login' && token) {
|
||||
return '/dashboard'
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
return {
|
||||
path: '/login',
|
||||
query: { redirect: to.fullPath }
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
48
apps/web/src/stores/app.ts
Normal file
48
apps/web/src/stores/app.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { api } from '@/composables/api'
|
||||
import type { Settings } from '@/types/api'
|
||||
|
||||
export const useAppStore = defineStore('app', () => {
|
||||
const settings = ref<Settings>({
|
||||
baseCurrency: 'CNY',
|
||||
defaultNotifyDays: 3,
|
||||
monthlyBudgetBase: null,
|
||||
yearlyBudgetBase: null,
|
||||
enableCategoryBudgets: false,
|
||||
categoryBudgets: {},
|
||||
emailNotificationsEnabled: false,
|
||||
pushplusNotificationsEnabled: false,
|
||||
emailConfig: {
|
||||
host: '',
|
||||
port: 587,
|
||||
secure: false,
|
||||
username: '',
|
||||
password: '',
|
||||
from: '',
|
||||
to: ''
|
||||
},
|
||||
pushplusConfig: {
|
||||
token: '',
|
||||
topic: ''
|
||||
},
|
||||
aiConfig: {
|
||||
enabled: false,
|
||||
providerName: 'DeepSeek',
|
||||
baseUrl: 'https://api.deepseek.com',
|
||||
apiKey: '',
|
||||
model: 'deepseek-chat',
|
||||
timeoutMs: 30000,
|
||||
promptTemplate: ''
|
||||
}
|
||||
})
|
||||
|
||||
async function refreshSettings() {
|
||||
settings.value = await api.getSettings()
|
||||
}
|
||||
|
||||
return {
|
||||
settings,
|
||||
refreshSettings
|
||||
}
|
||||
})
|
||||
30
apps/web/src/stores/auth.ts
Normal file
30
apps/web/src/stores/auth.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { api } from '@/composables/api'
|
||||
import { clearAuthSession, getStoredToken, getStoredUsername, saveAuthSession } from '@/utils/auth-storage'
|
||||
|
||||
export const useAuthStore = defineStore('auth', {
|
||||
state: () => ({
|
||||
token: getStoredToken() ?? '',
|
||||
username: getStoredUsername() ?? ''
|
||||
}),
|
||||
getters: {
|
||||
isAuthenticated: (state) => Boolean(state.token)
|
||||
},
|
||||
actions: {
|
||||
setSession(token: string, username: string) {
|
||||
this.token = token
|
||||
this.username = username
|
||||
saveAuthSession(token, username)
|
||||
},
|
||||
clearSession() {
|
||||
this.token = ''
|
||||
this.username = ''
|
||||
clearAuthSession()
|
||||
},
|
||||
async login(username: string, password: string) {
|
||||
const result = await api.login(username, password)
|
||||
this.setSession(result.token, result.user.username)
|
||||
return result
|
||||
}
|
||||
}
|
||||
})
|
||||
34
apps/web/src/style.css
Normal file
34
apps/web/src/style.css
Normal file
@@ -0,0 +1,34 @@
|
||||
:root {
|
||||
font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
|
||||
color: #111827;
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
#app {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
color: #6b7280;
|
||||
margin-top: 4px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.card-muted {
|
||||
color: #6b7280;
|
||||
}
|
||||
217
apps/web/src/types/api.ts
Normal file
217
apps/web/src/types/api.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
export type SubscriptionStatus = 'active' | 'paused' | 'cancelled' | 'expired'
|
||||
|
||||
export interface AuthUser {
|
||||
username: string
|
||||
}
|
||||
|
||||
export interface AuthResponse {
|
||||
token: string
|
||||
user: AuthUser
|
||||
}
|
||||
|
||||
export interface AuthUserResponse {
|
||||
user: AuthUser
|
||||
}
|
||||
|
||||
export interface LoginPayload {
|
||||
username: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export interface ChangeCredentialsPayload {
|
||||
oldUsername: string
|
||||
oldPassword: string
|
||||
newUsername: string
|
||||
newPassword: string
|
||||
}
|
||||
|
||||
export interface Category {
|
||||
id: string
|
||||
name: string
|
||||
color: string
|
||||
icon: string
|
||||
sortOrder: number
|
||||
}
|
||||
|
||||
export interface Subscription {
|
||||
id: string
|
||||
name: string
|
||||
categoryId?: string | null
|
||||
category?: Category | null
|
||||
description: string
|
||||
websiteUrl?: string | null
|
||||
logoUrl?: string | null
|
||||
logoSource?: string | null
|
||||
logoFetchedAt?: string | null
|
||||
status: SubscriptionStatus
|
||||
amount: number
|
||||
currency: string
|
||||
billingIntervalCount: number
|
||||
billingIntervalUnit: 'day' | 'week' | 'month' | 'quarter' | 'year'
|
||||
startDate: string
|
||||
nextRenewalDate: string
|
||||
notifyDaysBefore: number
|
||||
webhookEnabled: boolean
|
||||
notes: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface SubscriptionDetail extends Subscription {}
|
||||
|
||||
export interface CategoryBudgetUsage {
|
||||
categoryId: string
|
||||
name: string
|
||||
budget: number
|
||||
spent: number
|
||||
ratio: number
|
||||
}
|
||||
|
||||
export interface StatisticsOverview {
|
||||
activeSubscriptions: number
|
||||
upcoming7Days: number
|
||||
upcoming30Days: number
|
||||
monthlyEstimatedBase: number
|
||||
yearlyEstimatedBase: number
|
||||
monthlyBudgetBase?: number | null
|
||||
yearlyBudgetBase?: number | null
|
||||
monthlyBudgetUsageRatio?: number | null
|
||||
yearlyBudgetUsageRatio?: number | null
|
||||
categorySpend: Array<{ name: string; value: number }>
|
||||
monthlyTrend: Array<{ month: string; amount: number }>
|
||||
categoryBudgetUsage?: CategoryBudgetUsage[]
|
||||
currencyDistribution: Array<{ currency: string; amount: number }>
|
||||
upcomingRenewals: Array<{
|
||||
id: string
|
||||
name: string
|
||||
nextRenewalDate: string
|
||||
amount: number
|
||||
currency: string
|
||||
convertedAmount: number
|
||||
status: SubscriptionStatus
|
||||
}>
|
||||
}
|
||||
|
||||
export interface CalendarEvent {
|
||||
id: string
|
||||
title: string
|
||||
date: string
|
||||
currency: string
|
||||
amount: number
|
||||
convertedAmount: number
|
||||
status: SubscriptionStatus
|
||||
}
|
||||
|
||||
export interface EmailConfig {
|
||||
host: string
|
||||
port: number
|
||||
secure: boolean
|
||||
username: string
|
||||
password: string
|
||||
from: string
|
||||
to: string
|
||||
}
|
||||
|
||||
export interface PushplusConfig {
|
||||
token: string
|
||||
topic: string
|
||||
}
|
||||
|
||||
export interface AiConfig {
|
||||
enabled: boolean
|
||||
providerName: string
|
||||
baseUrl: string
|
||||
apiKey: string
|
||||
model: string
|
||||
timeoutMs: number
|
||||
promptTemplate: string
|
||||
}
|
||||
|
||||
export interface Settings {
|
||||
baseCurrency: string
|
||||
defaultNotifyDays: number
|
||||
monthlyBudgetBase?: number | null
|
||||
yearlyBudgetBase?: number | null
|
||||
enableCategoryBudgets: boolean
|
||||
categoryBudgets: Record<string, number>
|
||||
emailNotificationsEnabled: boolean
|
||||
pushplusNotificationsEnabled: boolean
|
||||
emailConfig: EmailConfig
|
||||
pushplusConfig: PushplusConfig
|
||||
aiConfig: AiConfig
|
||||
}
|
||||
|
||||
export interface NotificationWebhookSettings {
|
||||
id: string
|
||||
url: string
|
||||
secret: string
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export interface WebhookEndpoint {
|
||||
id: string
|
||||
name: string
|
||||
url: string
|
||||
secret: string
|
||||
enabled: boolean
|
||||
eventsJson: string[]
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface ExchangeRateSnapshot {
|
||||
baseCurrency: string
|
||||
rates: Record<string, number>
|
||||
fetchedAt: string
|
||||
provider: string
|
||||
isStale: boolean
|
||||
}
|
||||
|
||||
export interface WebhookDelivery {
|
||||
id: string
|
||||
endpointId: string
|
||||
eventType: string
|
||||
resourceKey: string
|
||||
periodKey: string
|
||||
status: 'pending' | 'success' | 'failed'
|
||||
responseCode?: number
|
||||
responseBody?: string
|
||||
attemptCount: number
|
||||
lastAttemptAt?: string
|
||||
createdAt: string
|
||||
endpoint?: {
|
||||
id: string
|
||||
name: string
|
||||
url: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface LogoSearchResult {
|
||||
label: string
|
||||
logoUrl: string
|
||||
source: string
|
||||
websiteUrl?: string
|
||||
width?: number
|
||||
height?: number
|
||||
isLocal?: boolean
|
||||
usageCount?: number
|
||||
filename?: string
|
||||
updatedAt?: string
|
||||
relatedSubscriptionNames?: string[]
|
||||
}
|
||||
|
||||
export interface AiRecognitionResult {
|
||||
name?: string
|
||||
description?: string
|
||||
amount?: number
|
||||
currency?: string
|
||||
billingIntervalCount?: number
|
||||
billingIntervalUnit?: 'day' | 'week' | 'month' | 'quarter' | 'year'
|
||||
startDate?: string
|
||||
nextRenewalDate?: string
|
||||
notifyDaysBefore?: number
|
||||
websiteUrl?: string
|
||||
notes?: string
|
||||
confidence?: number
|
||||
rawText?: string
|
||||
}
|
||||
20
apps/web/src/utils/auth-storage.ts
Normal file
20
apps/web/src/utils/auth-storage.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
const TOKEN_KEY = 'subtracker.auth.token'
|
||||
const USERNAME_KEY = 'subtracker.auth.username'
|
||||
|
||||
export function getStoredToken() {
|
||||
return window.localStorage.getItem(TOKEN_KEY)
|
||||
}
|
||||
|
||||
export function getStoredUsername() {
|
||||
return window.localStorage.getItem(USERNAME_KEY)
|
||||
}
|
||||
|
||||
export function saveAuthSession(token: string, username: string) {
|
||||
window.localStorage.setItem(TOKEN_KEY, token)
|
||||
window.localStorage.setItem(USERNAME_KEY, username)
|
||||
}
|
||||
|
||||
export function clearAuthSession() {
|
||||
window.localStorage.removeItem(TOKEN_KEY)
|
||||
window.localStorage.removeItem(USERNAME_KEY)
|
||||
}
|
||||
14
apps/web/src/utils/currency.ts
Normal file
14
apps/web/src/utils/currency.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import currencyNameMap from '@/data/currency-names.zh-CN.json'
|
||||
|
||||
export function getCurrencyLabel(code: string) {
|
||||
const upper = code.toUpperCase()
|
||||
const name = (currencyNameMap as Record<string, string>)[upper]
|
||||
return name ? `${name} (${upper})` : upper
|
||||
}
|
||||
|
||||
export function buildCurrencyOptions(currencies: string[]) {
|
||||
return currencies.map((currency) => ({
|
||||
label: getCurrencyLabel(currency),
|
||||
value: currency
|
||||
}))
|
||||
}
|
||||
7
apps/web/src/utils/logo.ts
Normal file
7
apps/web/src/utils/logo.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export function resolveLogoUrl(url?: string | null) {
|
||||
if (!url) return ''
|
||||
if (/^https?:\/\//i.test(url)) return url
|
||||
|
||||
const base = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:3001/api/v1'
|
||||
return new URL(url, base.replace(/\/api\/v1$/, '')).toString()
|
||||
}
|
||||
30
apps/web/src/utils/subscription-status.ts
Normal file
30
apps/web/src/utils/subscription-status.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { SubscriptionStatus } from '@/types/api'
|
||||
|
||||
export function getSubscriptionStatusText(status: SubscriptionStatus | string) {
|
||||
switch (status) {
|
||||
case 'active':
|
||||
return '正常'
|
||||
case 'paused':
|
||||
return '暂停'
|
||||
case 'cancelled':
|
||||
return '停用'
|
||||
case 'expired':
|
||||
return '过期'
|
||||
default:
|
||||
return status
|
||||
}
|
||||
}
|
||||
|
||||
export function getSubscriptionStatusTagType(status: SubscriptionStatus | string): 'default' | 'success' | 'warning' | 'error' {
|
||||
switch (status) {
|
||||
case 'active':
|
||||
return 'success'
|
||||
case 'paused':
|
||||
return 'warning'
|
||||
case 'cancelled':
|
||||
case 'expired':
|
||||
return 'error'
|
||||
default:
|
||||
return 'default'
|
||||
}
|
||||
}
|
||||
11
apps/web/tsconfig.json
Normal file
11
apps/web/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
},
|
||||
"types": ["vite/client", "node"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"]
|
||||
}
|
||||
57
apps/web/vite.config.ts
Normal file
57
apps/web/vite.config.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import path from 'node:path'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src')
|
||||
}
|
||||
},
|
||||
server: {
|
||||
port: 5173
|
||||
},
|
||||
build: {
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks(id) {
|
||||
if (!id.includes('node_modules')) return
|
||||
|
||||
if (id.includes('vue-router') || id.includes('/vue/') || id.includes('pinia')) {
|
||||
return 'vendor-vue'
|
||||
}
|
||||
|
||||
if (id.includes('naive-ui')) {
|
||||
if (id.includes('/data-table') || id.includes('/pagination')) {
|
||||
return 'vendor-naive-table'
|
||||
}
|
||||
if (id.includes('/date-picker') || id.includes('/select') || id.includes('/input') || id.includes('/form')) {
|
||||
return 'vendor-naive-form'
|
||||
}
|
||||
if (id.includes('/layout') || id.includes('/drawer') || id.includes('/menu') || id.includes('/tabs')) {
|
||||
return 'vendor-naive-layout'
|
||||
}
|
||||
return 'vendor-naive-core'
|
||||
}
|
||||
|
||||
if (id.includes('@vicons')) {
|
||||
return 'vendor-icons'
|
||||
}
|
||||
|
||||
if (id.includes('vue-echarts')) {
|
||||
return 'vendor-vcharts'
|
||||
}
|
||||
|
||||
if (id.includes('echarts') || id.includes('zrender')) {
|
||||
return 'vendor-echarts'
|
||||
}
|
||||
|
||||
if (id.includes('dayjs') || id.includes('axios') || id.includes('@tanstack') || id.includes('@vueuse')) {
|
||||
return 'vendor-utils'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
16
apps/web/vitest.config.ts
Normal file
16
apps/web/vitest.config.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import path from 'node:path'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src')
|
||||
}
|
||||
},
|
||||
test: {
|
||||
include: ['src/**/*.test.ts'],
|
||||
environment: 'jsdom'
|
||||
}
|
||||
})
|
||||
16
docker-compose.yml
Normal file
16
docker-compose.yml
Normal file
@@ -0,0 +1,16 @@
|
||||
services:
|
||||
api:
|
||||
image: ${SUBTRACKER_API_IMAGE:-ghcr.io/smile-qwq/subtracker-api:latest}
|
||||
container_name: subtracker-api
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
PORT: ${PORT:-3001}
|
||||
HOST: ${HOST:-0.0.0.0}
|
||||
DATABASE_URL: ${DATABASE_URL:-file:/app/data/subtracker.db}
|
||||
WEB_ORIGIN: ${WEB_ORIGIN:-https://subtracker.example.com}
|
||||
LOG_LEVEL: ${LOG_LEVEL:-warn}
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- ./data/logos:/app/apps/api/storage/logos
|
||||
ports:
|
||||
- '${PORT:-3001}:${PORT:-3001}'
|
||||
6331
package-lock.json
generated
Normal file
6331
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
package.json
Normal file
24
package.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "subtracker",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Subscription tracker with currency conversion, reminder webhooks, statistics and calendar.",
|
||||
"workspaces": [
|
||||
"apps/*",
|
||||
"packages/*"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "node scripts/dev.mjs",
|
||||
"build": "npm run build -w packages/shared && npm run build -w apps/api && npm run build -w apps/web",
|
||||
"test": "npm run test -w packages/shared && npm run test -w apps/api && npm run test -w apps/web",
|
||||
"lint": "npm run lint -w apps/api && npm run lint -w apps/web",
|
||||
"prisma:generate": "npm run prisma:generate -w apps/api",
|
||||
"prisma:migrate": "npm run prisma:migrate -w apps/api",
|
||||
"prisma:push": "npm run prisma:push -w apps/api",
|
||||
"prisma:seed": "npm run prisma:seed -w apps/api"
|
||||
},
|
||||
"devDependencies": {
|
||||
"concurrently": "^9.1.2",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
}
|
||||
24
packages/shared/package.json
Normal file
24
packages/shared/package.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "@subtracker/shared",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"module": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsup src/index.ts --format esm,cjs --dts",
|
||||
"test": "vitest run",
|
||||
"lint": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"zod": "^3.24.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tsup": "^8.4.0",
|
||||
"vitest": "^3.1.1"
|
||||
}
|
||||
}
|
||||
18
packages/shared/src/index.test.ts
Normal file
18
packages/shared/src/index.test.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { CreateSubscriptionSchema } from '../src/index'
|
||||
|
||||
describe('shared schema', () => {
|
||||
it('should validate create subscription payload', () => {
|
||||
const parsed = CreateSubscriptionSchema.parse({
|
||||
name: 'GitHub',
|
||||
amount: 10,
|
||||
currency: 'usd',
|
||||
billingIntervalUnit: 'month',
|
||||
startDate: '2026-04-01',
|
||||
nextRenewalDate: '2026-05-01'
|
||||
})
|
||||
|
||||
expect(parsed.currency).toBe('USD')
|
||||
expect(parsed.billingIntervalCount).toBe(1)
|
||||
})
|
||||
})
|
||||
219
packages/shared/src/index.ts
Normal file
219
packages/shared/src/index.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const SubscriptionStatusSchema = z.enum(['active', 'paused', 'cancelled', 'expired'])
|
||||
export const BillingIntervalUnitSchema = z.enum(['day', 'week', 'month', 'quarter', 'year'])
|
||||
export const WebhookEventTypeSchema = z.enum([
|
||||
'subscription.reminder_due',
|
||||
'subscription.overdue',
|
||||
'subscription.renewed',
|
||||
'exchange-rate.stale'
|
||||
])
|
||||
|
||||
export const CategorySchema = z.object({
|
||||
id: z.string().cuid().optional(),
|
||||
name: z.string().min(1).max(100),
|
||||
color: z.string().min(4).max(20).default('#3b82f6'),
|
||||
icon: z.string().max(50).default('apps-outline'),
|
||||
sortOrder: z.number().int().default(0)
|
||||
})
|
||||
|
||||
const OptionalMoneySchema = z.number().nonnegative().nullable().optional()
|
||||
|
||||
export const SubscriptionLogoSchema = z.object({
|
||||
websiteUrl: z.string().url().nullable().optional(),
|
||||
logoUrl: z.string().max(500).nullable().optional(),
|
||||
logoSource: z.string().max(100).nullable().optional()
|
||||
})
|
||||
|
||||
export const CreateSubscriptionSchema = z
|
||||
.object({
|
||||
name: z.string().min(1).max(150),
|
||||
categoryId: z.string().cuid().nullable().optional(),
|
||||
description: z.string().max(500).default(''),
|
||||
amount: z.number().positive(),
|
||||
currency: z.string().length(3).transform((v) => v.toUpperCase()),
|
||||
billingIntervalCount: z.number().int().positive().default(1),
|
||||
billingIntervalUnit: BillingIntervalUnitSchema,
|
||||
startDate: z.string().date(),
|
||||
nextRenewalDate: z.string().date(),
|
||||
notifyDaysBefore: z.number().int().min(0).max(365).default(3),
|
||||
webhookEnabled: z.boolean().default(true),
|
||||
notes: z.string().max(1000).default('')
|
||||
})
|
||||
.merge(SubscriptionLogoSchema)
|
||||
|
||||
export const UpdateSubscriptionSchema = CreateSubscriptionSchema.partial().extend({
|
||||
status: SubscriptionStatusSchema.optional()
|
||||
})
|
||||
|
||||
export const RenewSubscriptionSchema = z.object({
|
||||
paidAt: z.string().date().optional(),
|
||||
amount: z.number().positive().optional(),
|
||||
currency: z.string().length(3).optional()
|
||||
})
|
||||
|
||||
export const CreateWebhookEndpointSchema = z.object({
|
||||
name: z.string().min(1).max(100),
|
||||
url: z.string().url(),
|
||||
secret: z.string().min(3).max(200),
|
||||
enabled: z.boolean().default(true),
|
||||
events: z.array(WebhookEventTypeSchema).nonempty()
|
||||
})
|
||||
|
||||
export const UpdateWebhookEndpointSchema = CreateWebhookEndpointSchema.partial()
|
||||
|
||||
export const EmailConfigSchema = z.object({
|
||||
host: z.string().max(200).default(''),
|
||||
port: z.number().int().min(1).max(65535).default(587),
|
||||
secure: z.boolean().default(false),
|
||||
username: z.string().max(200).default(''),
|
||||
password: z.string().max(500).default(''),
|
||||
from: z.string().max(200).default(''),
|
||||
to: z.string().max(500).default('')
|
||||
})
|
||||
|
||||
export const PushPlusConfigSchema = z.object({
|
||||
token: z.string().max(200).default(''),
|
||||
topic: z.string().max(100).default('')
|
||||
})
|
||||
|
||||
export const AiConfigSchema = z.object({
|
||||
enabled: z.boolean().default(false),
|
||||
providerName: z.string().max(100).default('DeepSeek'),
|
||||
baseUrl: z.string().url().default('https://api.deepseek.com'),
|
||||
apiKey: z.string().max(500).default(''),
|
||||
model: z.string().max(100).default('deepseek-chat'),
|
||||
timeoutMs: z.number().int().min(5000).max(120000).default(30000),
|
||||
promptTemplate: z.string().max(5000).default('')
|
||||
})
|
||||
|
||||
export const SettingsSchema = z.object({
|
||||
baseCurrency: z.string().length(3).default('CNY').transform((v) => v.toUpperCase()),
|
||||
defaultNotifyDays: z.number().int().min(0).max(365).default(3),
|
||||
monthlyBudgetBase: OptionalMoneySchema,
|
||||
yearlyBudgetBase: OptionalMoneySchema,
|
||||
enableCategoryBudgets: z.boolean().default(false),
|
||||
categoryBudgets: z.record(z.string(), z.number().nonnegative()).default({}),
|
||||
emailNotificationsEnabled: z.boolean().default(false),
|
||||
pushplusNotificationsEnabled: z.boolean().default(false),
|
||||
emailConfig: EmailConfigSchema.default({}),
|
||||
pushplusConfig: PushPlusConfigSchema.default({}),
|
||||
aiConfig: AiConfigSchema.default({})
|
||||
})
|
||||
|
||||
export const LoginSchema = z.object({
|
||||
username: z.string().min(1).max(100),
|
||||
password: z.string().min(1).max(200)
|
||||
})
|
||||
|
||||
export const ChangeCredentialsSchema = z.object({
|
||||
oldUsername: z.string().min(1).max(100),
|
||||
oldPassword: z.string().min(1).max(200),
|
||||
newUsername: z.string().min(1).max(100),
|
||||
newPassword: z.string().min(4).max(200)
|
||||
})
|
||||
|
||||
export const LogoSearchSchema = z.object({
|
||||
name: z.string().min(1).max(150),
|
||||
websiteUrl: z.string().url().optional(),
|
||||
categoryName: z.string().max(100).optional()
|
||||
})
|
||||
|
||||
export const LogoUploadSchema = z.object({
|
||||
filename: z.string().min(1).max(200),
|
||||
contentType: z.string().min(1).max(100),
|
||||
base64: z.string().min(1)
|
||||
})
|
||||
|
||||
export const AiRecognizeSubscriptionSchema = z.object({
|
||||
text: z.string().max(8000).optional(),
|
||||
imageBase64: z.string().max(10_000_000).optional(),
|
||||
filename: z.string().max(200).optional(),
|
||||
mimeType: z.string().max(100).optional()
|
||||
})
|
||||
|
||||
export type SubscriptionStatus = z.infer<typeof SubscriptionStatusSchema>
|
||||
export type BillingIntervalUnit = z.infer<typeof BillingIntervalUnitSchema>
|
||||
export type WebhookEventType = z.infer<typeof WebhookEventTypeSchema>
|
||||
export type CreateSubscriptionInput = z.infer<typeof CreateSubscriptionSchema>
|
||||
export type UpdateSubscriptionInput = z.infer<typeof UpdateSubscriptionSchema>
|
||||
export type RenewSubscriptionInput = z.infer<typeof RenewSubscriptionSchema>
|
||||
export type CreateWebhookEndpointInput = z.infer<typeof CreateWebhookEndpointSchema>
|
||||
export type UpdateWebhookEndpointInput = z.infer<typeof UpdateWebhookEndpointSchema>
|
||||
export type SettingsInput = z.infer<typeof SettingsSchema>
|
||||
export type LoginInput = z.infer<typeof LoginSchema>
|
||||
export type ChangeCredentialsInput = z.infer<typeof ChangeCredentialsSchema>
|
||||
export type EmailConfigInput = z.infer<typeof EmailConfigSchema>
|
||||
export type PushPlusConfigInput = z.infer<typeof PushPlusConfigSchema>
|
||||
export type AiConfigInput = z.infer<typeof AiConfigSchema>
|
||||
export type LogoSearchInput = z.infer<typeof LogoSearchSchema>
|
||||
export type LogoUploadInput = z.infer<typeof LogoUploadSchema>
|
||||
export type AiRecognizeSubscriptionInput = z.infer<typeof AiRecognizeSubscriptionSchema>
|
||||
|
||||
export interface MoneyDto {
|
||||
amount: number
|
||||
currency: string
|
||||
}
|
||||
|
||||
export interface ExchangeRateSnapshotDto {
|
||||
baseCurrency: string
|
||||
rates: Record<string, number>
|
||||
fetchedAt: string
|
||||
provider: string
|
||||
isStale: boolean
|
||||
}
|
||||
|
||||
export interface LogoSearchResultDto {
|
||||
label: string
|
||||
logoUrl: string
|
||||
source: string
|
||||
websiteUrl?: string
|
||||
width?: number
|
||||
height?: number
|
||||
isLocal?: boolean
|
||||
usageCount?: number
|
||||
filename?: string
|
||||
updatedAt?: string
|
||||
relatedSubscriptionNames?: string[]
|
||||
}
|
||||
|
||||
export interface AiRecognitionResultDto {
|
||||
name?: string
|
||||
description?: string
|
||||
amount?: number
|
||||
currency?: string
|
||||
billingIntervalCount?: number
|
||||
billingIntervalUnit?: BillingIntervalUnit
|
||||
startDate?: string
|
||||
nextRenewalDate?: string
|
||||
notifyDaysBefore?: number
|
||||
websiteUrl?: string
|
||||
notes?: string
|
||||
confidence?: number
|
||||
rawText?: string
|
||||
}
|
||||
|
||||
export interface DashboardOverview {
|
||||
activeSubscriptions: number
|
||||
upcoming7Days: number
|
||||
upcoming30Days: number
|
||||
monthlyEstimatedBase: number
|
||||
yearlyEstimatedBase: number
|
||||
monthlyBudgetBase?: number | null
|
||||
yearlyBudgetBase?: number | null
|
||||
monthlyBudgetUsageRatio?: number | null
|
||||
yearlyBudgetUsageRatio?: number | null
|
||||
categorySpend: Array<{ name: string; value: number }>
|
||||
monthlyTrend: Array<{ month: string; amount: number }>
|
||||
categoryBudgetUsage?: Array<{ categoryId: string; name: string; budget: number; spent: number; ratio: number }>
|
||||
}
|
||||
|
||||
export interface CalendarEventDto {
|
||||
id: string
|
||||
title: string
|
||||
date: string
|
||||
currency: string
|
||||
amount: number
|
||||
convertedAmount: number
|
||||
status: SubscriptionStatus
|
||||
}
|
||||
9
packages/shared/tsconfig.json
Normal file
9
packages/shared/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"declaration": true,
|
||||
"declarationMap": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
78
scripts/dev.mjs
Normal file
78
scripts/dev.mjs
Normal file
@@ -0,0 +1,78 @@
|
||||
import { spawn } from 'node:child_process'
|
||||
|
||||
const npmExecPath = process.env.npm_execpath
|
||||
|
||||
if (!npmExecPath) {
|
||||
console.error('未找到 npm_execpath,无法启动 workspace dev 脚本。')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const children = []
|
||||
let shuttingDown = false
|
||||
|
||||
function startWorkspace(name, color, args) {
|
||||
const child = spawn(process.execPath, [npmExecPath, ...args], {
|
||||
cwd: process.cwd(),
|
||||
stdio: ['inherit', 'pipe', 'pipe'],
|
||||
env: {
|
||||
...process.env,
|
||||
FORCE_COLOR: '1'
|
||||
}
|
||||
})
|
||||
|
||||
child.stdout?.on('data', (chunk) => {
|
||||
process.stdout.write(`${color}[${name}]\x1b[0m ${chunk}`)
|
||||
})
|
||||
|
||||
child.stderr?.on('data', (chunk) => {
|
||||
process.stderr.write(`${color}[${name}]\x1b[0m ${chunk}`)
|
||||
})
|
||||
|
||||
child.on('exit', (code, signal) => {
|
||||
if (!shuttingDown) {
|
||||
if (signal) {
|
||||
console.log(`[${name}] 已退出,signal=${signal}`)
|
||||
} else {
|
||||
console.log(`[${name}] 已退出,code=${code}`)
|
||||
}
|
||||
shutdown(code ?? 0)
|
||||
}
|
||||
})
|
||||
|
||||
children.push(child)
|
||||
}
|
||||
|
||||
function shutdown(exitCode = 0) {
|
||||
if (shuttingDown) return
|
||||
shuttingDown = true
|
||||
|
||||
for (const child of children) {
|
||||
if (!child.killed) {
|
||||
child.kill('SIGINT')
|
||||
}
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
for (const child of children) {
|
||||
if (!child.killed) {
|
||||
child.kill('SIGTERM')
|
||||
}
|
||||
}
|
||||
}, 1200)
|
||||
|
||||
setTimeout(() => {
|
||||
process.exit(exitCode)
|
||||
}, 1800)
|
||||
}
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
console.log('\n正在关闭开发服务...')
|
||||
shutdown(0)
|
||||
})
|
||||
|
||||
process.on('SIGTERM', () => {
|
||||
shutdown(0)
|
||||
})
|
||||
|
||||
startWorkspace('api', '\x1b[36m', ['run', 'dev', '-w', 'apps/api'])
|
||||
startWorkspace('web', '\x1b[35m', ['run', 'dev', '-w', 'apps/web'])
|
||||
14
tsconfig.base.json
Normal file
14
tsconfig.base.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user