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> <dependency>
<groupId>mysql</groupId> <groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId> <artifactId>mysql-connector-java</artifactId>
<version>8.0.33</version>
<scope>runtime</scope> <scope>runtime</scope>
</dependency> </dependency>
<dependency> <dependency>

View File

@@ -51,6 +51,29 @@ public class ConfessionController {
return ApiResponse.ok(confessionRepository.save(confession)); 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 @PublicEndpoint
@GetMapping("/{code}") @GetMapping("/{code}")
public ApiResponse<Confession> get(@PathVariable String 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> { public interface ConfessionRepository extends JpaRepository<Confession, Long> {
Optional<Confession> findByCode(String code); 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.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry; import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry; import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration @Configuration
public class WebConfig implements WebMvcConfigurer { public class WebConfig implements WebMvcConfigurer {
private final AuthInterceptor authInterceptor; private final AuthInterceptor authInterceptor;
private final String allowedOrigins; private final String allowedOrigins;
private final String uploadDir;
public WebConfig(AuthInterceptor authInterceptor, 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.authInterceptor = authInterceptor;
this.allowedOrigins = allowedOrigins; this.allowedOrigins = allowedOrigins;
this.uploadDir = uploadDir;
} }
@Override @Override
@@ -30,4 +34,21 @@ public class WebConfig implements WebMvcConfigurer {
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowCredentials(true); .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: spring:
datasource: 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 username: root
password: root password: qq5211314
jpa: jpa:
hibernate: hibernate:
ddl-auto: update ddl-auto: update
@@ -16,9 +16,18 @@ spring:
jackson: jackson:
date-format: yyyy-MM-dd HH:mm:ss date-format: yyyy-MM-dd HH:mm:ss
time-zone: Asia/Shanghai time-zone: Asia/Shanghai
servlet:
multipart:
max-file-size: 10MB
max-request-size: 10MB
app: app:
cors: cors:
allowed-origins: http://localhost:8081 allowed-origins: http://localhost:8081
qr: qr:
base-url: http://localhost:8081/#/gift 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": { "dependencies": {
"axios": "^1.7.2", "axios": "^1.7.2",
"core-js": "^3.36.0", "core-js": "^3.36.0",
"element-china-area-data": "^6.1.0",
"element-ui": "^2.15.14", "element-ui": "^2.15.14",
"vue": "^2.7.16", "vue": "^2.7.16",
"vue-router": "^3.6.5" "vue-router": "^3.6.5"

View File

@@ -1,6 +1,8 @@
import http from './http'; import http from './http';
export const createConfession = (data) => http.post('/confessions', data); 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 getConfession = (code) => http.get(`/confessions/${code}`);
export const listBarrages = (code) => http.get(`/confessions/${code}/barrages`); export const listBarrages = (code) => http.get(`/confessions/${code}/barrages`);
export const sendBarrage = (code, data) => http.post(`/confessions/${code}/barrages`, data); 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-button type="text" @click="$router.push('/orders')">返回订单</el-button>
</el-card> </el-card>
<el-card class="content"> <el-card class="content">
<h3>定制告白弹幕</h3> <div class="title">定制告白弹幕</div>
<el-form :model="form"> <el-form :model="form" label-width="90px" class="form">
<el-form-item label="标题"> <el-form-item label="标题">
<el-input v-model="form.title" /> <el-input v-model="form.title" />
</el-form-item> </el-form-item>
<el-form-item label="祝福文字"> <el-form-item label="祝福文字">
<el-input type="textarea" v-model="form.message" /> <el-input type="textarea" v-model="form.message" />
</el-form-item> </el-form-item>
<el-form-item label="配图链接"> <el-form-item label="AI 生成">
<el-input v-model="form.imageUrl" placeholder="https://..." /> <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-item>
</el-form> </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"> <div v-if="confession" class="result">
<p>专属链接{{ giftUrl }}</p> <p>专属链接{{ giftUrl }}</p>
<img :src="qrUrl" class="qr" /> <img :src="qrUrl" class="qr" />
@@ -26,7 +45,7 @@
</template> </template>
<script> <script>
import { createConfession, getQrUrl } from '../api/confession'; import { createConfession, getConfessionByOrder, updateConfession, getQrUrl } from '../api/confession';
export default { export default {
data() { data() {
@@ -36,7 +55,9 @@ export default {
message: '', message: '',
imageUrl: '' imageUrl: ''
}, },
confession: null confession: null,
aiPrompt: ' 约50字文艺但不晦涩避免过度肉麻表达温柔与坚定。仅输出文案本身不要加标题或解释。',
aiLoading: false
}; };
}, },
computed: { computed: {
@@ -45,16 +66,84 @@ export default {
}, },
qrUrl() { qrUrl() {
return this.confession ? getQrUrl(this.confession.code) : ''; 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: { 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() { submit() {
createConfession({ const payload = { orderId: this.$route.params.orderId, ...this.form };
orderId: this.$route.params.orderId, const action = this.confession && this.confession.id
...this.form ? updateConfession(this.confession.id, payload)
}).then((res) => { : createConfession(payload);
action.then((res) => {
this.confession = res.data.data; this.confession = res.data.data;
this.$message.success('生成成功'); this.$message.success('保存成功');
}); });
} }
} }
@@ -68,9 +157,34 @@ export default {
.content { .content {
margin-top: 12px; 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 { .result {
margin-top: 16px; margin-top: 16px;
} }
.preview img {
margin-top: 8px;
width: 200px;
height: 200px;
object-fit: cover;
border-radius: 6px;
}
.qr { .qr {
width: 200px; width: 200px;
height: 200px; height: 200px;

View File

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

View File

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

View File

@@ -34,6 +34,7 @@
import { getProduct } from '../api/product'; import { getProduct } from '../api/product';
import { listAddresses } from '../api/address'; import { listAddresses } from '../api/address';
import { createOrder } from '../api/order'; import { createOrder } from '../api/order';
import { codeToText } from 'element-china-area-data';
export default { export default {
data() { data() {
@@ -65,7 +66,18 @@ export default {
}); });
}, },
formatAddress(addr) { 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() { submitOrder() {
if (!this.addressId) { if (!this.addressId) {

View File

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

View File

@@ -1,8 +1,14 @@
<template> <template>
<el-container class="layout"> <el-container class="layout">
<el-aside width="200px" class="aside"> <el-aside width="220px" class="aside">
<div class="title">后台管理</div> <div class="title">植愈后台</div>
<el-menu router> <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">仪表盘</el-menu-item>
<el-menu-item index="/admin/products">商品管理</el-menu-item> <el-menu-item index="/admin/products">商品管理</el-menu-item>
<el-menu-item index="/admin/orders">订单管理</el-menu-item> <el-menu-item index="/admin/orders">订单管理</el-menu-item>
@@ -26,14 +32,28 @@
min-height: 100vh; min-height: 100vh;
} }
.aside { .aside {
background: #2d3a4b; background: linear-gradient(180deg, #1f2d3d 0%, #233044 100%);
color: #fff; color: #fff;
} }
.title { .title {
padding: 20px; padding: 20px 16px;
font-size: 18px; font-size: 18px;
font-weight: 600;
letter-spacing: 1px;
} }
.header { .header {
background: #fff; 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> </style>

View File

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

View File

@@ -26,8 +26,19 @@
<el-form-item label="库存"> <el-form-item label="库存">
<el-input v-model="form.stock" /> <el-input v-model="form.stock" />
</el-form-item> </el-form-item>
<el-form-item label="封面"> <el-form-item label="封面上传">
<el-input v-model="form.coverUrl" /> <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>
<el-form-item label="描述"> <el-form-item label="描述">
<el-input type="textarea" v-model="form.description" /> <el-input type="textarea" v-model="form.description" />
@@ -55,13 +66,25 @@ export default {
return { return {
products: [], products: [],
showDialog: false, showDialog: false,
form: {} form: {},
uploadAction: '/api/upload'
}; };
}, },
computed: {
uploadHeaders() {
const token = localStorage.getItem('token');
return token ? { Authorization: `Bearer ${token}` } : {};
}
},
created() { created() {
this.load(); this.load();
}, },
methods: { methods: {
handleUpload(response) {
if (response && response.data && response.data.url) {
this.form.coverUrl = response.data.url;
}
},
load() { load() {
adminProducts.list().then((res) => { adminProducts.list().then((res) => {
this.products = res.data.data || []; this.products = res.data.data || [];
@@ -90,3 +113,13 @@ export default {
} }
}; };
</script> </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: { devServer: {
proxy: { proxy: {
'/api': { '/api': {
target: 'http://localhost:8080/',
changeOrigin: true
},
'/uploads': {
target: 'http://localhost:8080', target: 'http://localhost:8080',
changeOrigin: true changeOrigin: true
} }