This commit is contained in:
王子琦
2026-01-13 15:19:49 +08:00
parent f58e05d962
commit 6af59d985f
16 changed files with 339 additions and 33 deletions

View File

@@ -37,6 +37,7 @@
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.33</version>
<scope>runtime</scope>
</dependency>
<dependency>

View File

@@ -51,6 +51,29 @@ public class ConfessionController {
return ApiResponse.ok(confessionRepository.save(confession));
}
@GetMapping("/order/{orderId}")
public ApiResponse<Confession> getByOrder(@PathVariable Long orderId) {
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new ApiException(404, "订单不存在"));
if (!order.getUserId().equals(AuthContext.get().getId())) {
throw new ApiException(403, "无权限");
}
return ApiResponse.ok(confessionRepository.findByOrderId(orderId).orElse(null));
}
@PutMapping("/{id}")
public ApiResponse<Confession> update(@PathVariable Long id, @RequestBody CreateConfessionRequest request) {
Confession confession = confessionRepository.findById(id)
.orElseThrow(() -> new ApiException(404, "告白不存在"));
if (!confession.getUserId().equals(AuthContext.get().getId())) {
throw new ApiException(403, "无权限");
}
confession.setTitle(request.getTitle());
confession.setMessage(request.getMessage());
confession.setImageUrl(request.getImageUrl());
return ApiResponse.ok(confessionRepository.save(confession));
}
@PublicEndpoint
@GetMapping("/{code}")
public ApiResponse<Confession> get(@PathVariable String code) {

View File

@@ -6,4 +6,5 @@ import java.util.Optional;
public interface ConfessionRepository extends JpaRepository<Confession, Long> {
Optional<Confession> findByCode(String code);
Optional<Confession> findByOrderId(Long orderId);
}

View File

@@ -5,17 +5,21 @@ import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebConfig implements WebMvcConfigurer {
private final AuthInterceptor authInterceptor;
private final String allowedOrigins;
private final String uploadDir;
public WebConfig(AuthInterceptor authInterceptor,
@Value("${app.cors.allowed-origins}") String allowedOrigins) {
@Value("${app.cors.allowed-origins}") String allowedOrigins,
@Value("${app.upload.dir}") String uploadDir) {
this.authInterceptor = authInterceptor;
this.allowedOrigins = allowedOrigins;
this.uploadDir = uploadDir;
}
@Override
@@ -30,4 +34,21 @@ public class WebConfig implements WebMvcConfigurer {
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowCredentials(true);
}
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/uploads/**")
.addResourceLocations("file:" + toFileLocation(uploadDir));
}
private String toFileLocation(String path) {
String normalized = path.replace("\\", "/");
if (normalized.matches("^[A-Za-z]:/.*")) {
return "/" + normalized + "/";
}
if (!normalized.startsWith("/")) {
return "/" + normalized + "/";
}
return normalized.endsWith("/") ? normalized : normalized + "/";
}
}

View File

@@ -3,9 +3,9 @@ server:
spring:
datasource:
url: jdbc:mysql://localhost:3306/flower_shop?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai
url: jdbc:mysql://localhost:3307/flower_shop?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai
username: root
password: root
password: qq5211314
jpa:
hibernate:
ddl-auto: update
@@ -16,9 +16,18 @@ spring:
jackson:
date-format: yyyy-MM-dd HH:mm:ss
time-zone: Asia/Shanghai
servlet:
multipart:
max-file-size: 10MB
max-request-size: 10MB
app:
cors:
allowed-origins: http://localhost:8081
qr:
base-url: http://localhost:8081/#/gift
upload:
dir: D:/bs/flower/files
ai:
token: b676775d829147dea4955ce809cc1beb.GZIuVsLf19YT5B12
model: glm-4.7

View File

@@ -9,6 +9,7 @@
"dependencies": {
"axios": "^1.7.2",
"core-js": "^3.36.0",
"element-china-area-data": "^6.1.0",
"element-ui": "^2.15.14",
"vue": "^2.7.16",
"vue-router": "^3.6.5"

View File

@@ -1,6 +1,8 @@
import http from './http';
export const createConfession = (data) => http.post('/confessions', data);
export const getConfessionByOrder = (orderId) => http.get(`/confessions/order/${orderId}`);
export const updateConfession = (id, data) => http.put(`/confessions/${id}`, data);
export const getConfession = (code) => http.get(`/confessions/${code}`);
export const listBarrages = (code) => http.get(`/confessions/${code}/barrages`);
export const sendBarrage = (code, data) => http.post(`/confessions/${code}/barrages`, data);

View File

@@ -4,19 +4,38 @@
<el-button type="text" @click="$router.push('/orders')">返回订单</el-button>
</el-card>
<el-card class="content">
<h3>定制告白弹幕</h3>
<el-form :model="form">
<div class="title">定制告白弹幕</div>
<el-form :model="form" label-width="90px" class="form">
<el-form-item label="标题">
<el-input v-model="form.title" />
</el-form-item>
<el-form-item label="祝福文字">
<el-input type="textarea" v-model="form.message" />
</el-form-item>
<el-form-item label="配图链接">
<el-input v-model="form.imageUrl" placeholder="https://..." />
<el-form-item label="AI 生成">
<div class="ai-row">
<el-input v-model="aiPrompt" placeholder="例如生成3条简短表白祝福语" />
<el-button type="primary" :loading="aiLoading" @click="generateWithAi">AI 生成</el-button>
</div>
</el-form-item>
<el-form-item label="配图上传">
<el-upload
:action="uploadAction"
:headers="uploadHeaders"
name="file"
:show-file-list="false"
:on-success="handleUpload"
>
<el-button type="primary">上传图片</el-button>
</el-upload>
<div v-if="form.imageUrl" class="preview">
<img :src="form.imageUrl" alt="preview" />
</div>
</el-form-item>
</el-form>
<el-button type="primary" @click="submit">生成页面</el-button>
<div class="actions">
<el-button type="primary" @click="submit">生成页面</el-button>
</div>
<div v-if="confession" class="result">
<p>专属链接{{ giftUrl }}</p>
<img :src="qrUrl" class="qr" />
@@ -26,7 +45,7 @@
</template>
<script>
import { createConfession, getQrUrl } from '../api/confession';
import { createConfession, getConfessionByOrder, updateConfession, getQrUrl } from '../api/confession';
export default {
data() {
@@ -36,7 +55,9 @@ export default {
message: '',
imageUrl: ''
},
confession: null
confession: null,
aiPrompt: ' 约50字文艺但不晦涩避免过度肉麻表达温柔与坚定。仅输出文案本身不要加标题或解释。',
aiLoading: false
};
},
computed: {
@@ -45,16 +66,84 @@ export default {
},
qrUrl() {
return this.confession ? getQrUrl(this.confession.code) : '';
},
uploadAction() {
return '/api/upload';
},
uploadHeaders() {
const token = localStorage.getItem('token');
return token ? { Authorization: `Bearer ${token}` } : {};
}
},
created() {
this.loadExisting();
},
methods: {
loadExisting() {
const orderId = this.$route.params.orderId;
getConfessionByOrder(orderId).then((res) => {
const data = res.data.data;
if (data) {
this.confession = data;
this.form.title = data.title || '';
this.form.message = data.message || '';
this.form.imageUrl = data.imageUrl || '';
}
});
},
generateWithAi() {
if (!this.aiPrompt) {
this.$message.warning('请输入提示词');
return;
}
this.aiLoading = true;
fetch('https://yunwu.ai/v1/chat/completions', {
method: 'POST',
headers: {
Authorization: 'Bearer sk-sloS0Nm2VJRPJKJ1c3D2nD4w68d3IESuvJng4NxEnSk1SdhK',
'Content-Type': 'application/json'
},
body: JSON.stringify({
model: 'gpt-4o',
messages: [
{ role: 'system', content: '你是文艺风格的祝福文案写手,语言自然、真诚、不过度夸张。' },
{ role: 'user', content: '请生成一条表白文案,要求如下:'+this.aiPrompt }
],
stream: false,
temperature: 1
})
})
.then((res) => res.json())
.then((res) => {
const content = res && res.choices && res.choices[0] && res.choices[0].message
? res.choices[0].message.content
: '';
if (!content) {
this.$message.error('AI 返回为空');
return;
}
this.form.message = content;
})
.catch(() => {
this.$message.error('AI 生成失败');
})
.finally(() => {
this.aiLoading = false;
});
},
handleUpload(response) {
if (response && response.data && response.data.url) {
this.form.imageUrl = response.data.url;
}
},
submit() {
createConfession({
orderId: this.$route.params.orderId,
...this.form
}).then((res) => {
const payload = { orderId: this.$route.params.orderId, ...this.form };
const action = this.confession && this.confession.id
? updateConfession(this.confession.id, payload)
: createConfession(payload);
action.then((res) => {
this.confession = res.data.data;
this.$message.success('生成成功');
this.$message.success('保存成功');
});
}
}
@@ -68,9 +157,34 @@ export default {
.content {
margin-top: 12px;
}
.title {
font-size: 18px;
font-weight: 600;
margin-bottom: 12px;
}
.form {
max-width: 720px;
}
.ai-row {
display: flex;
gap: 10px;
}
.ai-row .el-input {
flex: 1;
}
.actions {
margin-top: 8px;
}
.result {
margin-top: 16px;
}
.preview img {
margin-top: 8px;
width: 200px;
height: 200px;
object-fit: cover;
border-radius: 6px;
}
.qr {
width: 200px;
height: 200px;

View File

@@ -6,8 +6,9 @@
<div>
<el-button type="text" @click="$router.push('/orders')">我的订单</el-button>
<el-button type="text" @click="$router.push('/profile')">个人中心</el-button>
<el-button type="text" @click="$router.push('/admin')">后台管理</el-button>
<el-button type="primary" @click="$router.push('/login')">登录</el-button>
<el-button v-if="isAdmin" type="text" @click="$router.push('/admin')">后台管理</el-button>
<el-button v-if="!isLoggedIn" type="primary" @click="$router.push('/login')">登录</el-button>
<el-button v-else type="danger" @click="handleLogout">注销</el-button>
</div>
</el-header>
<el-main>
@@ -41,6 +42,7 @@
<script>
import { listProducts, listCategories } from '../api/product';
import { logout } from '../api/auth';
export default {
data() {
@@ -50,14 +52,36 @@ export default {
categories: [],
categoryId: null,
keyword: '',
placeholder: 'https://via.placeholder.com/300x200?text=Flower'
placeholder: 'https://via.placeholder.com/300x200?text=Flower',
isAdmin: false,
isLoggedIn: false
};
},
created() {
const user = JSON.parse(localStorage.getItem('user') || 'null');
this.isAdmin = user && user.role === 'ADMIN';
this.isLoggedIn = Boolean(localStorage.getItem('token'));
this.loadCategories();
this.loadProducts();
},
methods: {
handleLogout() {
const token = localStorage.getItem('token');
if (token) {
this.$confirm('确认退出登录?', '提示', { type: 'warning' })
.then(() => {
logout().finally(() => {
this.$message.success('已退出登录');
localStorage.removeItem('token');
localStorage.removeItem('user');
this.isLoggedIn = false;
this.isAdmin = false;
this.$router.push('/login');
});
})
.catch(() => {});
}
},
loadCategories() {
listCategories().then((res) => {
this.categories = res.data.data || [];

View File

@@ -6,7 +6,11 @@
<el-card class="content">
<el-table :data="orders">
<el-table-column prop="order.orderNo" label="订单号" />
<el-table-column prop="order.status" label="状态" />
<el-table-column label="状态">
<template slot-scope="scope">
{{ statusText(scope.row.order.status) }}
</template>
</el-table-column>
<el-table-column prop="order.totalAmount" label="金额" />
<el-table-column label="操作">
<template slot-scope="scope">
@@ -33,6 +37,16 @@ export default {
this.loadOrders();
},
methods: {
statusText(status) {
const map = {
CREATED: '待支付',
PAID: '已支付',
SHIPPED: '已发货',
COMPLETED: '已完成',
CANCELED: '已取消'
};
return map[status] || status;
},
loadOrders() {
listOrders().then((res) => {
this.orders = res.data.data || [];

View File

@@ -34,6 +34,7 @@
import { getProduct } from '../api/product';
import { listAddresses } from '../api/address';
import { createOrder } from '../api/order';
import { codeToText } from 'element-china-area-data';
export default {
data() {
@@ -65,7 +66,18 @@ export default {
});
},
formatAddress(addr) {
return `${addr.recipientName} ${addr.phone} ${addr.province || ''}${addr.city || ''}${addr.district || ''}${addr.detail || ''}`;
const province = this.formatRegion(addr.province);
const city = this.formatRegion(addr.city);
const district = this.formatRegion(addr.district);
return `${addr.recipientName} ${addr.phone} ${province}${city}${district}${addr.detail || ''}`;
},
formatRegion(value) {
if (!value) return '';
const key = String(value);
if (codeToText && codeToText[key]) {
return codeToText[key];
}
return value;
},
submitOrder() {
if (!this.addressId) {

View File

@@ -50,9 +50,13 @@
<el-input v-model="addressForm.phone" />
</el-form-item>
<el-form-item label="省市区">
<el-input v-model="addressForm.province" placeholder="省" />
<el-input v-model="addressForm.city" placeholder="市" />
<el-input v-model="addressForm.district" placeholder="区" />
<el-cascader
v-model="region"
:options="regionOptions"
:props="regionProps"
placeholder="请选择省市区"
clearable
/>
</el-form-item>
<el-form-item label="详细地址">
<el-input v-model="addressForm.detail" />
@@ -73,6 +77,7 @@
import { me } from '../api/auth';
import { listAddresses, createAddress, updateAddress, deleteAddress } from '../api/address';
import http from '../api/http';
import { regionData } from 'element-china-area-data';
export default {
data() {
@@ -80,7 +85,10 @@ export default {
profile: { nickname: '', phone: '', email: '' },
addresses: [],
showDialog: false,
addressForm: {}
addressForm: {},
regionOptions: regionData,
regionProps: { value: 'label', label: 'label', children: 'children' },
region: []
};
},
created() {
@@ -105,6 +113,7 @@ export default {
},
editAddress(row) {
this.addressForm = { ...row };
this.region = [row.province, row.city, row.district].filter(Boolean);
this.showDialog = true;
},
removeAddress(id) {
@@ -114,11 +123,15 @@ export default {
});
},
saveAddress() {
this.addressForm.province = this.region[0] || '';
this.addressForm.city = this.region[1] || '';
this.addressForm.district = this.region[2] || '';
const api = this.addressForm.id ? updateAddress(this.addressForm.id, this.addressForm) : createAddress(this.addressForm);
api.then(() => {
this.$message.success('保存成功');
this.showDialog = false;
this.addressForm = {};
this.region = [];
this.loadAddresses();
});
}

View File

@@ -1,8 +1,14 @@
<template>
<el-container class="layout">
<el-aside width="200px" class="aside">
<div class="title">后台管理</div>
<el-menu router>
<el-aside width="220px" class="aside">
<div class="title">植愈后台</div>
<el-menu
router
background-color="#1f2d3d"
text-color="#c0c4cc"
active-text-color="#409eff"
:default-active="$route.path"
>
<el-menu-item index="/admin">仪表盘</el-menu-item>
<el-menu-item index="/admin/products">商品管理</el-menu-item>
<el-menu-item index="/admin/orders">订单管理</el-menu-item>
@@ -26,14 +32,28 @@
min-height: 100vh;
}
.aside {
background: #2d3a4b;
background: linear-gradient(180deg, #1f2d3d 0%, #233044 100%);
color: #fff;
}
.title {
padding: 20px;
padding: 20px 16px;
font-size: 18px;
font-weight: 600;
letter-spacing: 1px;
}
.header {
background: #fff;
border-bottom: 1px solid #f0f0f0;
}
.el-menu {
border-right: none;
}
.el-menu-item {
height: 48px;
line-height: 48px;
}
.el-menu-item.is-active {
background: rgba(64, 158, 255, 0.15) !important;
border-right: 3px solid #409eff;
}
</style>

View File

@@ -2,7 +2,11 @@
<div>
<el-table :data="orders">
<el-table-column prop="order.orderNo" label="订单号" />
<el-table-column prop="order.status" label="状态" />
<el-table-column label="状态">
<template slot-scope="scope">
{{ statusText(scope.row.order.status) }}
</template>
</el-table-column>
<el-table-column prop="order.totalAmount" label="金额" />
<el-table-column label="操作">
<template slot-scope="scope">
@@ -31,6 +35,16 @@ export default {
this.load();
},
methods: {
statusText(status) {
const map = {
CREATED: '待支付',
PAID: '已支付',
SHIPPED: '已发货',
COMPLETED: '已完成',
CANCELED: '已取消'
};
return map[status] || status;
},
load() {
adminOrders.list().then((res) => {
this.orders = res.data.data || [];

View File

@@ -26,8 +26,19 @@
<el-form-item label="库存">
<el-input v-model="form.stock" />
</el-form-item>
<el-form-item label="封面">
<el-input v-model="form.coverUrl" />
<el-form-item label="封面上传">
<el-upload
:action="uploadAction"
:headers="uploadHeaders"
name="file"
:show-file-list="false"
:on-success="handleUpload"
>
<el-button type="primary">上传图片</el-button>
</el-upload>
<div v-if="form.coverUrl" class="preview">
<img :src="form.coverUrl" alt="cover" />
</div>
</el-form-item>
<el-form-item label="描述">
<el-input type="textarea" v-model="form.description" />
@@ -55,13 +66,25 @@ export default {
return {
products: [],
showDialog: false,
form: {}
form: {},
uploadAction: '/api/upload'
};
},
computed: {
uploadHeaders() {
const token = localStorage.getItem('token');
return token ? { Authorization: `Bearer ${token}` } : {};
}
},
created() {
this.load();
},
methods: {
handleUpload(response) {
if (response && response.data && response.data.url) {
this.form.coverUrl = response.data.url;
}
},
load() {
adminProducts.list().then((res) => {
this.products = res.data.data || [];
@@ -90,3 +113,13 @@ export default {
}
};
</script>
<style scoped>
.preview img {
margin-top: 8px;
width: 120px;
height: 120px;
object-fit: cover;
border-radius: 6px;
}
</style>

View File

@@ -2,6 +2,10 @@ module.exports = {
devServer: {
proxy: {
'/api': {
target: 'http://localhost:8080/',
changeOrigin: true
},
'/uploads': {
target: 'http://localhost:8080',
changeOrigin: true
}