6 Commits

Author SHA1 Message Date
luo
861ffa371b feat: 站内通知
All checks were successful
构建并部署前端到生产环境 / build-and-deploy (push) Successful in 54s
2026-07-25 11:28:58 +08:00
luo
f6a33121f2 feat: 站内通知
All checks were successful
构建并部署前端到生产环境 / build-and-deploy (push) Successful in 59s
2026-07-25 11:22:16 +08:00
luo
133a4d18ef feat: 站内通知
Some checks failed
构建并部署前端到生产环境 / build-and-deploy (push) Has been cancelled
2026-07-25 11:21:42 +08:00
luo
9c1c296d2e feat: 站内通知
All checks were successful
构建并部署前端到生产环境 / build-and-deploy (push) Successful in 1m29s
2026-07-25 11:08:03 +08:00
luo
3929156ef4 fix: git-config
All checks were successful
构建并部署前端到生产环境 / build-and-deploy (push) Successful in 1m45s
2026-07-24 12:16:04 +08:00
luo
ad3c9b964d fix: git-config 2026-07-24 12:11:03 +08:00
16 changed files with 427 additions and 18 deletions

2
.gitattributes vendored Normal file
View File

@@ -0,0 +1,2 @@
*.html linguist-detectable=false
*.vue linguist-detectable=true

View File

@@ -3,7 +3,7 @@ name: 构建并部署前端到生产环境
on:
push:
branches:
- main
- develop
- dev
- test
@@ -28,7 +28,7 @@ jobs:
- name: 设置镜像标签
id: tag
run: |
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
if [ "${{ github.ref }}" = "refs/heads/develop" ]; then
echo "tag=latest" >> $GITHUB_OUTPUT
elif [ "${{ github.ref }}" = "refs/heads/dev" ]; then
echo "tag=dev" >> $GITHUB_OUTPUT
@@ -52,8 +52,8 @@ jobs:
docker push ${{ env.IMAGE_NAME }}:${{ steps.tag.outputs.tag }}
docker push ${{ env.IMAGE_NAME }}:${{ github.sha }}
- name: 部署到本地(仅 main 分支)
if: github.ref == 'refs/heads/main'
- name: 部署到本地(仅 develop 分支)
if: github.ref == 'refs/heads/develop'
run: |
mkdir -p ${{ env.DEPLOY_DIR }}

15
.gitignore vendored
View File

@@ -2,9 +2,22 @@
logs
*.log
npm-debug.log*
npminstall-debug.log
yarn-debug.log*
yarn-error.log*
# Common local files
dist-ssr
*.local
.cursorrules
.claude
.codex
.env.development
.env.production
.scratch
.agents
Thumbs.db
# Runtime data
pids
*.pid
@@ -91,4 +104,4 @@ dist/
# OS
.DS_Store
Thumbs.db
Thumbs.db

View File

@@ -9,14 +9,17 @@ RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories
# 设置工作目录
WORKDIR /build
# 设置 npm 镜像源
RUN npm config set registry https://registry.npmmirror.com
# 安装 pnpm
RUN corepack enable && corepack prepare pnpm@10.15.0 --activate
# 复制 package.json利用 Docker 缓存)
COPY package.json ./
# 设置 pnpm 镜像源
RUN pnpm config set registry https://registry.npmmirror.com
# 复制 package.json 和 pnpm-lock.yaml利用 Docker 缓存)
COPY package.json pnpm-lock.yaml ./
# 安装所有依赖
RUN npm install --legacy-peer-deps
RUN pnpm install --frozen-lockfile
# 安装 git (用于获取 commit 信息)
RUN apk add --no-cache git
@@ -24,21 +27,18 @@ RUN apk add --no-cache git
# 复制源代码
COPY . .
# 列出文件确认复制成功
RUN ls -la
# 创建 src 目录软链接 (兼容 uni-app 新版本)
RUN mkdir -p src && \
ln -sf /build/manifest.json /build/src/manifest.json && \
ln -sf /build/pages.json /build/src/pages.json
# 构建 H5 生产版本
RUN npm run build:h5
RUN pnpm run build:h5
# ================================
# 阶段 2: 运行阶段
# ================================
FROM --platform=linux/amd64 nginx:alpine
FROM --platform=linux/amd64 nginx:1.31.2-alpine
# 使用阿里云镜像源加速
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories

View File

@@ -3,6 +3,7 @@ export { assetApi } from './modules/asset.js';
export { deviceApi } from './modules/device.js';
export { exchangeApi } from './modules/exchange.js';
export { orderApi } from './modules/order.js';
export { notificationApi } from './modules/notification.js';
export { realnameApi } from './modules/realname.js';
export { walletApi } from './modules/wallet.js';
export { wechatApi } from './modules/wechat.js';
export { wechatApi } from './modules/wechat.js';

View File

@@ -0,0 +1,32 @@
import request from '@/utils/request.js';
export const notificationApi = {
getList(page = 1, page_size = 20) {
return request({
url: '/api/c/v1/notifications',
method: 'GET',
data: { page, page_size }
});
},
getUnreadCount() {
return request({
url: '/api/c/v1/notifications/unread-count',
method: 'GET'
});
},
markRead(id) {
return request({
url: `/api/c/v1/notifications/${id}/read`,
method: 'PUT'
});
},
markAllRead() {
return request({
url: '/api/c/v1/notifications/read-all',
method: 'PUT'
});
}
};

View File

@@ -29,6 +29,14 @@
</view>
<view class="function-name">套餐订购</view>
</view>
<view class="function-item interactive card-interactive" @tap="$emit('enter', 'notifications')" role="button"
tabindex="0">
<view class="function-icon function-icon-badge">
<image src="/static/notification.png" mode="aspectFit" alt="站内通知"></image>
<view v-if="unreadCount > 0" class="unread-badge">{{ unreadCount > 99 ? '99+' : unreadCount }}</view>
</view>
<view class="function-name">站内通知</view>
</view>
<view class="function-item interactive card-interactive" @tap="$emit('enter', 'order-list')" role="button"
tabindex="0">
<view class="function-icon">
@@ -132,6 +140,10 @@
walletBalance: {
type: [Number, String],
default: 0
},
unreadCount: {
type: Number,
default: 0
}
});
@@ -188,6 +200,22 @@
}
}
.function-icon-badge { position: relative; }
.unread-badge {
position: absolute;
top: -8rpx;
right: -12rpx;
min-width: 34rpx;
height: 34rpx;
padding: 0 8rpx;
border-radius: 20rpx;
background: var(--danger);
color: #fff;
font-size: 18rpx;
line-height: 34rpx;
text-align: center;
}
.function-name {
font-size: 28rpx;
font-weight: 500;

View File

@@ -9,6 +9,9 @@ services:
- '3003:80'
networks:
- device-voice-network
tmpfs:
- /run:rw
- /tmp:rw
healthcheck:
test: ['CMD', 'wget', '--no-verbose', '--tries=1', '--spider', 'http://127.0.0.1:80/health']
interval: 30s

View File

@@ -0,0 +1,45 @@
# 个人客户站内通知
为个人客户增加通知入口,支持查询通知、查看未读数以及标记已读。所有接口均需要登录,使用 `Bearer Token` 鉴权。
## 查询通知列表
`GET /api/c/v1/notifications`
查询当前客户可见的未过期业务通知,按创建时间和通知 ID 倒序排列。
| 参数 | 必填 | 说明 |
| --- | --- | --- |
| `page` | 否 | 页码,默认 1范围 110000 |
| `page_size` | 否 | 每页数量,默认 20范围 150 |
返回:包含通知列表 `items`、当前页 `page`、每页数量 `size` 和总数 `total`。通知项包含标题、正文、类型、类别、级别、关联资源、已读状态及创建/已读时间。
## 标记单条通知已读
`PUT /api/c/v1/notifications/{id}/read`
将当前客户可见的指定通知标记为已读。通知不存在、属于其他客户或已经已读时,也返回成功,接口幂等。
返回:`{ "success": true }`
## 全部标记已读
`PUT /api/c/v1/notifications/read-all`
将当前客户可见的未过期未读业务通知全部标记为已读,重复调用幂等。
返回:`{ "updated_count": 3 }`
## 查询未读数
`GET /api/c/v1/notifications/unread-count`
查询当前客户未过期业务通知的未读数量;平台同步和系统运维通知不计入结果。
返回:`{ "count": 3, "display_count": "3" }`,数量超过 99 时 `display_count``99+`
## 通用约定
- 所有接口均使用 `Bearer Token` 鉴权。
- 未登录、参数错误、无权访问和服务异常沿用统一错误响应。

View File

@@ -0,0 +1,19 @@
# Change: Add personal customer notifications
## Why
个人客户缺少统一的站内通知入口,无法查看业务通知、获取未读数量或管理通知已读状态。
## What Changes
- 增加个人客户通知列表查询接口,支持分页和固定倒序排列。
- 增加单条通知已读和全部通知已读接口。
- 增加个人客户通知未读数查询接口。
- 限制通知数据仅返回当前认证客户可见且未过期的业务通知。
- 统一返回通知内容、关联资源、通知级别、已读状态及时间信息。
## Impact
- Affected specs: `personal-notifications`
- Affected code: 个人客户通知 API、通知列表入口、未读数展示和已读状态处理
- No breaking changes to existing APIs

View File

@@ -0,0 +1,56 @@
## ADDED Requirements
### Requirement: Personal customers SHALL be able to query notifications
The system SHALL provide `GET /api/c/v1/notifications` for authenticated personal customers. The endpoint SHALL return only visible, unexpired business notifications for the current customer, ordered by creation time descending and notification ID descending.
#### Scenario: Query the first notification page
- **WHEN** an authenticated customer requests the notification list without pagination parameters
- **THEN** the system SHALL return page 1 with up to 20 notifications
- **AND** the response SHALL include `items`, `page`, `size`, and `total`
#### Scenario: Apply valid pagination
- **WHEN** the request includes `page` from 1 to 10000 and `page_size` from 1 to 50
- **THEN** the system SHALL return the requested page and page size
#### Scenario: Reject invalid pagination
- **WHEN** `page` or `page_size` is outside its allowed range
- **THEN** the system SHALL return a parameter validation error
### Requirement: Notification items SHALL expose client-readable status and metadata
Each notification item SHALL include `id`, `title`, `body`, `type`, `category`, `severity`, `ref_type`, `ref_id`, `ref_key`, `is_read`, `created_at`, and nullable `read_at`.
#### Scenario: Return an unread notification
- **WHEN** a visible notification has not been read by the current customer
- **THEN** the system SHALL return `is_read: false` and `read_at: null`
### Requirement: Personal customers SHALL be able to mark one notification as read
The system SHALL provide `PUT /api/c/v1/notifications/{id}/read` for authenticated personal customers. The operation SHALL be idempotent and SHALL only update a notification visible to the current customer.
#### Scenario: Mark a visible unread notification
- **WHEN** the customer marks a visible unread notification as read
- **THEN** the system SHALL set its read state and return `{ "success": true }`
#### Scenario: Mark an unavailable or already-read notification
- **WHEN** the notification does not exist, belongs to another customer, or is already read
- **THEN** the system SHALL return `{ "success": true }` without exposing ownership information
### Requirement: Personal customers SHALL be able to mark all notifications as read
The system SHALL provide `PUT /api/c/v1/notifications/read-all` to mark all visible, unexpired, unread business notifications for the current customer as read.
#### Scenario: Mark all unread notifications
- **WHEN** the customer calls the mark-all-read endpoint
- **THEN** the system SHALL return the number of notifications actually updated in `updated_count`
- **AND** repeated calls SHALL remain successful and return zero when nothing needs updating
### Requirement: Personal customers SHALL be able to query unread count
The system SHALL provide `GET /api/c/v1/notifications/unread-count`. The count SHALL include only visible, unexpired business notifications and SHALL exclude platform sync and system operations notifications.
#### Scenario: Return unread count and badge text
- **WHEN** an authenticated customer queries the unread count
- **THEN** the system SHALL return numeric `count` and string `display_count`
- **AND** `display_count` SHALL be `99+` when `count` exceeds 99

View File

@@ -0,0 +1,26 @@
## 1. API and Data Contract
- [x] 1.1 Define the client-side personal notification item and paginated list contract
- [x] 1.2 Define the client-side unread count and read-operation contracts
- [x] 1.3 Add authenticated notification API wrappers
## 2. Notification Behavior
- [ ] 2.1 Return only current customer's visible, unexpired business notifications
- [ ] 2.2 Apply creation-time and notification-ID descending ordering
- [ ] 2.3 Support idempotent single-read and read-all operations
- [ ] 2.4 Exclude platform sync and system operations notifications from unread count
## 3. Client Entry
- [x] 3.1 Add the personal notification list entry and pagination handling
- [x] 3.2 Add unread-count display and refresh behavior
- [x] 3.3 Mark notifications read from list actions and support mark-all-read
## 4. Verification
- [ ] 4.1 Test authentication, pagination limits, ordering, and visibility isolation
- [ ] 4.2 Test idempotent read behavior and unread-count updates
- [x] 4.3 Verify the H5 build and unified success/error response handling
> Note: The repository contains the H5 client only. Tasks 2.1-2.4 and 4.1-4.2 require implementation and verification in the backend service repository.

View File

@@ -44,6 +44,12 @@
"navigationBarTitleText": "我的订单"
}
},
{
"path": "pages/notifications/notifications",
"style": {
"navigationBarTitleText": "站内通知"
}
},
{
"path": "pages/bind/bind",
"style": {

View File

@@ -17,7 +17,8 @@
<WifiCard v-if="userInfo.isDevice" :deviceInfo="deviceInfo" @modify="modifyWifi" @copy="copy" />
<FunctionCard :realNameStatus="realNameStatus" :alreadyBindPhone="alreadyBindPhone"
:isDevice="userInfo.isDevice" :walletBalance="deviceInfo.walletBalance" @enter="enterDetail" @sync="onSync">
:isDevice="userInfo.isDevice" :walletBalance="deviceInfo.walletBalance" :unreadCount="notificationUnreadCount"
@enter="enterDetail" @sync="onSync">
<!-- 修改WIFI弹窗 -->
<up-popup :show="showModifyWifi" mode="center" @close="showModifyWifi=false">
<view class="wifi-popup">
@@ -124,7 +125,8 @@
import FloatingButton from '@/components/FloatingButton.vue';
import {
assetApi,
deviceApi
deviceApi,
notificationApi
} from '@/api/index.js';
import {
useUserStore
@@ -210,6 +212,7 @@
let smsCodePhone = ref('');
let smsCode = ref('');
let indexEntryChecking = ref(false);
let notificationUnreadCount = ref(0);
const formatDate = (dateStr) => {
if (!dateStr) return '-';
@@ -699,6 +702,11 @@
const enterDetail = (name) => {
switch (name) {
case 'notifications':
uni.navigateTo({
url: '/pages/notifications/notifications'
});
break;
case 'package-order':
uni.navigateTo({
url: '/pages/package-order/package-order'
@@ -767,12 +775,22 @@
}
};
const loadNotificationUnreadCount = async () => {
try {
const data = await notificationApi.getUnreadCount();
notificationUnreadCount.value = Number(data?.count || 0);
} catch (error) {
console.error('加载通知未读数失败', error);
}
};
onMounted(() => {
initCurrentMonth();
});
onShow(() => {
handleIndexEntry();
loadNotificationUnreadCount();
});
</script>

View File

@@ -0,0 +1,160 @@
<template>
<view class="container">
<view class="toolbar flex-row-sb">
<view class="summary"> {{ total }} 条通知</view>
<button class="btn-apple btn-secondary mark-all" :disabled="unreadCount === 0" @tap="markAllRead">
全部已读
</button>
</view>
<view v-if="notifications.length === 0 && !loading" class="empty-state">
<image src="/static/notification.png" mode="aspectFit" class="empty-icon" />
<view class="empty-title">暂无通知</view>
<view class="empty-desc">当前没有可查看的业务通知</view>
</view>
<view v-else class="notification-list">
<view v-for="item in notifications" :key="item.id" class="card notification-card"
:class="{ unread: !item.is_read }" @tap="markItemRead(item)">
<view class="notification-header flex-row-sb">
<view class="notification-title">{{ item.title || '业务通知' }}</view>
<view v-if="!item.is_read" class="unread-dot"></view>
</view>
<view class="notification-body">{{ item.body || '-' }}</view>
<view class="notification-footer flex-row-sb">
<view class="notification-meta">{{ getCategoryText(item.category) }} · {{ getSeverityText(item.severity) }}</view>
<view class="notification-time">{{ formatDate(item.created_at) }}</view>
</view>
</view>
</view>
<view v-if="notifications.length > 0" class="load-more" @tap="loadMore">
<text v-if="loading">加载中...</text>
<text v-else-if="noMore">没有更多了</text>
<text v-else>点击加载更多</text>
</view>
</view>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue';
import { onShow } from '@dcloudio/uni-app';
import { notificationApi } from '@/api/index.js';
const pageSize = 20;
const notifications = reactive([]);
const page = ref(1);
const total = ref(0);
const unreadCount = ref(0);
const loading = ref(false);
const noMore = ref(false);
const formatDate = (value) => {
if (!value) return '-';
return value.replace('T', ' ').slice(0, 16);
};
const getCategoryText = (category) => ({
approval: '审批',
expiry: '临期',
sync: '同步',
system: '系统'
}[category] || '通知');
const getSeverityText = (severity) => ({
info: '提示',
warning: '警告',
error: '错误',
critical: '严重'
}[severity] || '提示');
const loadUnreadCount = async () => {
try {
const data = await notificationApi.getUnreadCount();
unreadCount.value = Number(data?.count || 0);
} catch (error) {
console.error('加载通知未读数失败', error);
}
};
const loadNotifications = async (append = false) => {
if (loading.value || (append && noMore.value)) return;
loading.value = true;
try {
const data = await notificationApi.getList(page.value, pageSize);
const items = data?.items || [];
if (append) {
notifications.push(...items);
} else {
notifications.splice(0, notifications.length, ...items);
}
total.value = Number(data?.total || 0);
noMore.value = notifications.length >= total.value || items.length < pageSize;
if (!noMore.value) page.value += 1;
} catch (error) {
console.error('加载通知列表失败', error);
} finally {
loading.value = false;
}
};
const markItemRead = async (item) => {
if (item.is_read) return;
try {
await notificationApi.markRead(item.id);
item.is_read = true;
item.read_at = new Date().toISOString();
unreadCount.value = Math.max(unreadCount.value - 1, 0);
} catch (error) {
console.error('标记通知已读失败', error);
}
};
const markAllRead = async () => {
if (!unreadCount.value) return;
try {
await notificationApi.markAllRead();
notifications.forEach(item => {
item.is_read = true;
item.read_at = item.read_at || new Date().toISOString();
});
unreadCount.value = 0;
uni.showToast({ title: '已全部标记为已读', icon: 'success' });
} catch (error) {
console.error('全部标记通知已读失败', error);
}
};
const loadMore = () => {
if (!noMore.value) loadNotifications(true);
};
onMounted(() => {
loadNotifications();
loadUnreadCount();
});
onShow(() => {
loadUnreadCount();
});
</script>
<style lang="scss" scoped>
.container { padding-bottom: 40rpx; }
.toolbar { margin-bottom: var(--space-md); }
.summary { color: var(--text-tertiary); font-size: 24rpx; }
.mark-all { margin: 0; padding: 0 24rpx; height: 64rpx; line-height: 64rpx; font-size: 24rpx; }
.notification-card { margin-bottom: var(--space-md); border-left: 6rpx solid transparent; }
.notification-card.unread { border-left-color: var(--primary); }
.notification-header { margin-bottom: var(--space-sm); }
.notification-title { color: var(--text-primary); font-size: 30rpx; font-weight: 600; flex: 1; }
.unread-dot { width: 14rpx; height: 14rpx; margin-left: 16rpx; border-radius: 50%; background: var(--primary); }
.notification-body { color: var(--text-secondary); font-size: 26rpx; line-height: 1.6; white-space: pre-wrap; }
.notification-footer { margin-top: var(--space-md); }
.notification-meta, .notification-time { color: var(--text-tertiary); font-size: 22rpx; }
.empty-state { display: flex; flex-direction: column; align-items: center; padding: 140rpx 40rpx; }
.empty-icon { width: 100rpx; height: 100rpx; opacity: 0.6; margin-bottom: 24rpx; }
.empty-title { color: var(--text-primary); font-size: 30rpx; font-weight: 600; }
.empty-desc { color: var(--text-tertiary); font-size: 24rpx; margin-top: 12rpx; }
.load-more { padding: var(--space-lg); color: var(--text-tertiary); text-align: center; font-size: 24rpx; }
</style>

BIN
static/notification.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB