feat: initialize SubTracker repository

This commit is contained in:
SmileQWQ
2026-04-11 13:36:43 +08:00
commit 81418ea57d
93 changed files with 16516 additions and 0 deletions

11
.dockerignore Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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"
}
}

View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,9 @@
import 'fastify'
declare module 'fastify' {
interface FastifyRequest {
auth?: {
username: string
}
}
}

19
apps/api/src/http.ts Normal file
View 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
View 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
View 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')
}
})
}

View 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)
})
}

View 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)
})
}

View 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)
}
})
}

View 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)
}
})
}

View 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')
}
})
}

View 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)
})
}

View 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)
})
}

View 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')
}
})
}

View 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)
})
}

View 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()
}
}

View 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
}
}

View 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
)
}

View 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)
}

View 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
}
}

View 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
}
})
}

View 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)
}
})
}

View 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
}
}

View 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
}))
}
}

View 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
})
}

View 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
}
})
}

View 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
View 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
}
}

View 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')
}

View 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)
}

View 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')
}

View 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)
})
})

View 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)
})
})

View 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)
})
})

View 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
View File

@@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src/**/*.ts", "tests/**/*.ts", "prisma/seed.ts"]
}

View 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
View 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
View 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
View 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>

View 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>

View 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>

View 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>

View 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>

View 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')
})
})

View 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>

View 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>

View 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>

View 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>

View 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>

View 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> })
}
}

View 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
View 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
View 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')

View 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>

View 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>

View 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>

View 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>

View 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>

File diff suppressed because it is too large Load Diff

View 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
})

View 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
}
})

View 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
View 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
View 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
}

View 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)
}

View 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
}))
}

View 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()
}

View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

24
package.json Normal file
View 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"
}
}

View 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"
}
}

View 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)
})
})

View 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
}

View 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
View 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
View File

@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true
}
}